From d094c73f07127aff150989db72753167e7887db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:31:15 +0200 Subject: [PATCH 01/72] refactor: separate timekeeping and reporting, renames --- spec/include/config.spec.js | 2 +- spec/models/time.title.spec.js | 8 +-- src/{include => core}/cli.js | 0 src/{include => core}/config.js | 2 +- src/{include => core}/file-config.js | 0 src/{include => core}/filesystem.js | 0 src/{models/base.js => core/gitlab-client.js} | 23 ++++---- src/{models => core}/issue.js | 41 ++------------ src/{models => core}/mergeRequest.js | 24 ++------ src/{models => core}/owner.js | 4 +- src/core/resource.js | 29 ++++++++++ src/{models => core}/time.js | 0 src/gtt.js | 26 ++++----- src/{models => reporting/api}/dayReport.js | 0 src/reporting/api/issue.js | 9 +++ src/reporting/api/mergeRequest.js | 9 +++ src/{models => reporting/api}/project.js | 4 +- src/{models => reporting/api}/report.js | 4 +- .../api}/reportCollection.js | 4 +- .../api/reportable.js} | 55 +++---------------- .../commands/report.js} | 33 +++++------ src/{ => reporting}/output/base.js | 9 +-- src/{ => reporting}/output/csv.js | 4 +- src/{ => reporting}/output/invoice.js | 4 +- src/{ => reporting}/output/markdown.js | 4 +- .../output/styles/highlight/default.css | 0 .../output/styles/layout/default.css | 0 src/{ => reporting}/output/table.js | 4 +- src/{ => reporting}/output/xlsx.js | 6 +- src/timekeeping/api/issue.js | 43 +++++++++++++++ src/timekeeping/api/mergeRequest.js | 26 +++++++++ src/timekeeping/api/writable.js | 27 +++++++++ .../commands/cancel.js} | 6 +- .../commands/config.js} | 4 +- .../commands/create.js} | 6 +- .../commands/delete.js} | 8 +-- .../commands/edit.js} | 10 ++-- .../commands/list.js} | 6 +- .../commands/log.js} | 10 ++-- .../commands/resume.js} | 10 ++-- .../commands/start.js} | 6 +- .../commands/status.js} | 6 +- .../commands/stop.js} | 6 +- .../commands/sync.js} | 8 +-- .../storage}/baseFrame.js | 2 +- src/{models => timekeeping/storage}/frame.js | 0 .../storage}/frameCollection.js | 6 +- src/{include => timekeeping/storage}/tasks.js | 10 ++-- 48 files changed, 281 insertions(+), 227 deletions(-) rename src/{include => core}/cli.js (100%) rename src/{include => core}/config.js (98%) rename src/{include => core}/file-config.js (100%) rename src/{include => core}/filesystem.js (100%) rename src/{models/base.js => core/gitlab-client.js} (89%) rename src/{models => core}/issue.js (62%) rename src/{models => core}/mergeRequest.js (75%) rename src/{models => core}/owner.js (97%) create mode 100755 src/core/resource.js rename src/{models => core}/time.js (100%) rename src/{models => reporting/api}/dayReport.js (100%) create mode 100644 src/reporting/api/issue.js create mode 100644 src/reporting/api/mergeRequest.js rename src/{models => reporting/api}/project.js (94%) rename src/{models => reporting/api}/report.js (98%) rename src/{models => reporting/api}/reportCollection.js (83%) rename src/{models/hasTimes.js => reporting/api/reportable.js} (84%) mode change 100755 => 100644 rename src/{gtt-report.js => reporting/commands/report.js} (95%) rename src/{ => reporting}/output/base.js (97%) rename src/{ => reporting}/output/csv.js (98%) rename src/{ => reporting}/output/invoice.js (99%) rename src/{ => reporting}/output/markdown.js (96%) rename src/{ => reporting}/output/styles/highlight/default.css (100%) rename src/{ => reporting}/output/styles/layout/default.css (100%) rename src/{ => reporting}/output/table.js (98%) rename src/{ => reporting}/output/xlsx.js (96%) create mode 100644 src/timekeeping/api/issue.js create mode 100644 src/timekeeping/api/mergeRequest.js create mode 100644 src/timekeeping/api/writable.js rename src/{gtt-cancel.js => timekeeping/commands/cancel.js} (88%) rename src/{gtt-config.js => timekeeping/commands/config.js} (83%) rename src/{gtt-create.js => timekeeping/commands/create.js} (90%) rename src/{gtt-delete.js => timekeeping/commands/delete.js} (83%) rename src/{gtt-edit.js => timekeeping/commands/edit.js} (91%) rename src/{gtt-list.js => timekeeping/commands/list.js} (90%) rename src/{gtt-log.js => timekeeping/commands/log.js} (94%) rename src/{gtt-resume.js => timekeeping/commands/resume.js} (94%) rename src/{gtt-start.js => timekeeping/commands/start.js} (92%) rename src/{gtt-status.js => timekeeping/commands/status.js} (91%) rename src/{gtt-stop.js => timekeeping/commands/stop.js} (88%) rename src/{gtt-sync.js => timekeeping/commands/sync.js} (88%) rename src/{models => timekeeping/storage}/baseFrame.js (98%) rename src/{models => timekeeping/storage}/frame.js (100%) rename src/{models => timekeeping/storage}/frameCollection.js (88%) rename src/{include => timekeeping/storage}/tasks.js (96%) diff --git a/spec/include/config.spec.js b/spec/include/config.spec.js index a2ffe60..0ff2a13 100755 --- a/spec/include/config.spec.js +++ b/spec/include/config.spec.js @@ -1,4 +1,4 @@ -import config from '../../src/include/config.js'; +import config from '../../src/core/config.js'; import { expect } from 'chai'; describe('The config class', () => { diff --git a/spec/models/time.title.spec.js b/spec/models/time.title.spec.js index 5b34eec..98ccac7 100644 --- a/spec/models/time.title.spec.js +++ b/spec/models/time.title.spec.js @@ -1,9 +1,9 @@ import moment from 'moment'; -import Config from '../../src/include/file-config.js'; -import Time from '../../src/models/time.js'; -import issue from '../../src/models/issue.js'; -import mergeRequest from '../../src/models/mergeRequest.js'; +import Config from '../../src/core/file-config.js'; +import Time from '../../src/core/time.js'; +import issue from '../../src/core/issue.js'; +import mergeRequest from '../../src/core/mergeRequest.js'; import { expect } from 'chai'; describe('time class', () => { diff --git a/src/include/cli.js b/src/core/cli.js similarity index 100% rename from src/include/cli.js rename to src/core/cli.js diff --git a/src/include/config.js b/src/core/config.js similarity index 98% rename from src/include/config.js rename to src/core/config.js index d91e303..ad8cdd6 100755 --- a/src/include/config.js +++ b/src/core/config.js @@ -1,6 +1,6 @@ import moment from 'moment'; import _ from 'underscore'; -import Time from './../models/time.js'; +import Time from './time.js'; import EventEmitter from 'events'; const dates = ['from', 'to']; diff --git a/src/include/file-config.js b/src/core/file-config.js similarity index 100% rename from src/include/file-config.js rename to src/core/file-config.js diff --git a/src/include/filesystem.js b/src/core/filesystem.js similarity index 100% rename from src/include/filesystem.js rename to src/core/filesystem.js diff --git a/src/models/base.js b/src/core/gitlab-client.js similarity index 89% rename from src/models/base.js rename to src/core/gitlab-client.js index 0a8d482..350df16 100755 --- a/src/models/base.js +++ b/src/core/gitlab-client.js @@ -3,14 +3,15 @@ import crypto from 'crypto'; import throttleFactory from 'throttled-queue'; /** - * base model + * GitLab REST/GraphQL client: owns the request throttle, single/parallel + * paginated GET, POST and GraphQL. Root of the API-model hierarchy. */ -class base { +class GitlabClient { static throttle; - + static init(config) { - if(base.throttle == undefined){ - base.throttle = throttleFactory(config.data.throttleMaxRequestsPerInterval, config.data.throttleInterval); + if(GitlabClient.throttle == undefined){ + GitlabClient.throttle = throttleFactory(config.data.throttleMaxRequestsPerInterval, config.data.throttleInterval); } } @@ -20,7 +21,7 @@ class base { * @param config */ constructor(config) { - base.init(config); + GitlabClient.init(config); this.config = config; this.url = config.get('url').endsWith('/') ? config.get('url') : `${config.get('url')}/`; @@ -41,7 +42,7 @@ class base { data.private_token = this.token; - return new Promise((resolve, reject) => base.throttle(() => { + return new Promise((resolve, reject) => GitlabClient.throttle(() => { fetch(`${this.url}${path}`, { method: 'POST', headers: { @@ -72,7 +73,7 @@ class base { // remove v4/ from url, add graphql const path = this.url.substr(0, this.url.length-3) + 'graphql'; - return new Promise((resolve, reject) => base.throttle(() => { + return new Promise((resolve, reject) => GitlabClient.throttle(() => { fetch(`${path}`, { method: 'POST', headers: { @@ -107,7 +108,7 @@ class base { path += (path.includes('?') ? '&' : '?') + `private_token=${this.token}`; path += `&page=${page}&per_page=${perPage}`; - return new Promise((resolve, reject) => base.throttle(() => { + return new Promise((resolve, reject) => GitlabClient.throttle(() => { fetch(`${this.url}${path}`, { headers: { 'PRIVATE-TOKEN': this.token @@ -144,7 +145,7 @@ class base { if (pages === 1) return resolve(collect); - let tasks = base.createGetTasks(path, pages, 2, perPage); + let tasks = GitlabClient.createGetTasks(path, pages, 2, perPage); this.getParallel(tasks, collect, runners).then(() => { resolve(collect); }).catch(error => reject(error)); @@ -205,4 +206,4 @@ class base { } } -export default base; +export default GitlabClient; diff --git a/src/models/issue.js b/src/core/issue.js similarity index 62% rename from src/models/issue.js rename to src/core/issue.js index 77f6a5d..e9c8f88 100755 --- a/src/models/issue.js +++ b/src/core/issue.js @@ -1,50 +1,17 @@ import _ from 'underscore'; import moment from 'moment'; -import hasTimes from './hasTimes.js'; +import resource from './resource.js'; /** - * issue model + * issue model — shared data/getters. Write ops (make/list/createTime) live in + * timekeeping/api/issue.js; read/aggregation in reporting/api/issue.js. */ -class issue extends hasTimes { +class issue extends resource { constructor(config, data = {}) { super(config); this.data = data; } - make(project, id, create = false) { - let promise; - - if (create) { - promise = this.post(`projects/${encodeURIComponent(project)}/issues`, {title: id}); - } else { - promise = this.get(`projects/${encodeURIComponent(project)}/issues/${id}`); - } - - promise.then(issue => { - this.data = issue.body; - return promise; - }); - - return promise; - } - - list(project, state, my) { - return new Promise((resolve, reject) => { - let promise; - const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; - if (project) { - promise = this.get(`projects/${encodeURIComponent(project)}/issues?${query}`); - } else { - promise = this.get(`issues/?${query}`); - } - promise.then(response => { - const issues = response.body.map(issue => new this.constructor(this.config, issue)) - resolve(issues) - }); - promise.catch(error => reject(error)) - }) - } - /* * properties */ diff --git a/src/models/mergeRequest.js b/src/core/mergeRequest.js similarity index 75% rename from src/models/mergeRequest.js rename to src/core/mergeRequest.js index 68d3e8d..ce24b42 100755 --- a/src/models/mergeRequest.js +++ b/src/core/mergeRequest.js @@ -1,32 +1,16 @@ import _ from 'underscore'; -import hasTimes from './hasTimes.js'; +import resource from './resource.js'; /** - * merge request model + * merge request model — shared data/getters. Write ops (make/createTime) live in + * timekeeping/api/mergeRequest.js; read/aggregation in reporting/api/mergeRequest.js. */ -class mergeRequest extends hasTimes { +class mergeRequest extends resource { constructor(config, data = {}) { super(config); this.data = data; } - make(project, id, create = false) { - let promise; - - if (create) { - promise = this.post(`projects/${encodeURIComponent(project)}/merge_requests`, {title: id}); - } else { - promise = this.get(`projects/${encodeURIComponent(project)}/merge_requests/${id}`); - } - - promise.then(issue => { - this.data = issue.body; - return promise; - }); - - return promise; - } - /* * properties */ diff --git a/src/models/owner.js b/src/core/owner.js similarity index 97% rename from src/models/owner.js rename to src/core/owner.js index 66f2f17..9f16cb7 100755 --- a/src/models/owner.js +++ b/src/core/owner.js @@ -1,10 +1,10 @@ import _ from 'underscore'; -import Base from './base.js'; +import GitlabClient from './gitlab-client.js'; /** * owner model */ -class owner extends Base { +class owner extends GitlabClient { constructor(config) { super(config); this.projects = []; diff --git a/src/core/resource.js b/src/core/resource.js new file mode 100755 index 0000000..9d967e1 --- /dev/null +++ b/src/core/resource.js @@ -0,0 +1,29 @@ +import GitlabClient from './gitlab-client.js'; + +/** + * shared base for models that have times (issues, merge requests). + * Holds only what both timekeeping and reporting need: the notes read. + * Timekeeping write methods live in timekeeping/api/writable.js, + * reporting read/aggregation lives in reporting/api/reportable.js. + */ +class resource extends GitlabClient { + constructor(config) { + super(config); + this.times = []; + this.timesWarnings = []; + this.days = {}; + } + + /** + * set notes + * @returns {Promise} + */ + getNotes() { + let promise = this.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); + promise.then(notes => this.notes = notes); + + return promise; + } +} + +export default resource; diff --git a/src/models/time.js b/src/core/time.js similarity index 100% rename from src/models/time.js rename to src/core/time.js diff --git a/src/gtt.js b/src/gtt.js index ba67446..bb05049 100755 --- a/src/gtt.js +++ b/src/gtt.js @@ -4,19 +4,19 @@ import version from './version.js'; import { program } from 'commander'; -import start from './gtt-start.js'; -import create from './gtt-create.js'; -import status from './gtt-status.js'; -import stop from './gtt-stop.js'; -import resume from './gtt-resume.js'; -import cancel from './gtt-cancel.js'; -import list from './gtt-list.js'; -import log from './gtt-log.js'; -import sync from './gtt-sync.js'; -import edit from './gtt-edit.js'; -import delCmd from './gtt-delete.js'; -import report from './gtt-report.js'; -import config from './gtt-config.js'; +import start from './timekeeping/commands/start.js'; +import create from './timekeeping/commands/create.js'; +import status from './timekeeping/commands/status.js'; +import stop from './timekeeping/commands/stop.js'; +import resume from './timekeeping/commands/resume.js'; +import cancel from './timekeeping/commands/cancel.js'; +import list from './timekeeping/commands/list.js'; +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 .version(version) diff --git a/src/models/dayReport.js b/src/reporting/api/dayReport.js similarity index 100% rename from src/models/dayReport.js rename to src/reporting/api/dayReport.js diff --git a/src/reporting/api/issue.js b/src/reporting/api/issue.js new file mode 100644 index 0000000..c8d4bb6 --- /dev/null +++ b/src/reporting/api/issue.js @@ -0,0 +1,9 @@ +import CoreIssue from '../../core/issue.js'; +import reportable from './reportable.js'; + +/** + * issue with reporting read/aggregation (getStats, getTimes, recordTimelogs). + */ +class issue extends reportable(CoreIssue) {} + +export default issue; diff --git a/src/reporting/api/mergeRequest.js b/src/reporting/api/mergeRequest.js new file mode 100644 index 0000000..66f1b61 --- /dev/null +++ b/src/reporting/api/mergeRequest.js @@ -0,0 +1,9 @@ +import CoreMergeRequest from '../../core/mergeRequest.js'; +import reportable from './reportable.js'; + +/** + * merge request with reporting read/aggregation (getStats, getTimes, recordTimelogs). + */ +class mergeRequest extends reportable(CoreMergeRequest) {} + +export default mergeRequest; diff --git a/src/models/project.js b/src/reporting/api/project.js similarity index 94% rename from src/models/project.js rename to src/reporting/api/project.js index 8209162..9c60ba4 100755 --- a/src/models/project.js +++ b/src/reporting/api/project.js @@ -1,9 +1,9 @@ -import Base from './base.js'; +import GitlabClient from '../../core/gitlab-client.js'; /** * project model */ -class project extends Base { +class project extends GitlabClient { /** * construct * @param config diff --git a/src/models/report.js b/src/reporting/api/report.js similarity index 98% rename from src/models/report.js rename to src/reporting/api/report.js index b93cbae..6f487ea 100755 --- a/src/models/report.js +++ b/src/reporting/api/report.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import moment from 'moment'; -import Base from './base.js'; +import GitlabClient from '../../core/gitlab-client.js'; import Issue from './issue.js'; import MergeRequest from './mergeRequest.js'; import Project from './project.js'; @@ -8,7 +8,7 @@ import Project from './project.js'; /** * report model */ -class report extends Base { +class report extends GitlabClient { /** * constructor. * @param config diff --git a/src/models/reportCollection.js b/src/reporting/api/reportCollection.js similarity index 83% rename from src/models/reportCollection.js rename to src/reporting/api/reportCollection.js index b44275a..aa352a5 100755 --- a/src/models/reportCollection.js +++ b/src/reporting/api/reportCollection.js @@ -1,7 +1,7 @@ -import Base from './base.js'; +import GitlabClient from '../../core/gitlab-client.js'; let projlist = []; -class reportCollection extends Base { +class reportCollection extends GitlabClient { constructor(config) { super(config); diff --git a/src/models/hasTimes.js b/src/reporting/api/reportable.js old mode 100755 new mode 100644 similarity index 84% rename from src/models/hasTimes.js rename to src/reporting/api/reportable.js index 0bdedbe..486d793 --- a/src/models/hasTimes.js +++ b/src/reporting/api/reportable.js @@ -1,7 +1,6 @@ import _ from 'underscore'; import moment from 'moment'; -import Base from './base.js'; -import Time from './time.js'; +import Time from '../../core/time.js'; import DayReport from './dayReport.js'; const regex = /added (.*) of time spent(?: at ([0-9-]*))?/i; @@ -10,35 +9,10 @@ const delRegex = /deleted (.*) of spent time(?: from ([0-9-]*))?/i; const removeRegex = /Removed time spent/i; /** - * base model for models that have times + * mixin: adds reporting read/aggregation to a core issue/mergeRequest class. + * @param Base a core issue/mergeRequest class */ -class hasTimes extends Base { - constructor(config) { - super(config); - this.times = []; - this.timesWarnings = []; - this.days = {}; - } - - /** - * create time - * @param time - * @returns {*} - */ - createTime(time, created_at, note) { - if(note === null || note === undefined) { - note = ''; - } - else { - note = '\n\n' + note; - } - var date = new Date(created_at); - var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); - return this.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { - body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt + note), - }); - } - +export default Base => class extends Base { /** * set stats * @returns {Promise} @@ -50,17 +24,6 @@ class hasTimes extends Base { return promise; } - /** - * set notes - * @returns {Promise} - */ - getNotes() { - let promise = this.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); - promise.then(notes => this.notes = notes); - - return promise; - } - recordTimelogs(timelogs){ let spentFreeLabels = this.config.get('freeLabels'); @@ -71,7 +34,7 @@ class hasTimes extends Base { if(undefined === spentHalfPriceLabels) { spentHalfPriceLabels = []; } - + let free = false; let halfPrice = false; this.labels.forEach(label => { @@ -89,7 +52,7 @@ class hasTimes extends Base { let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); - + timelogs.forEach( (timelog) => { let spentAt = moment(timelog.spentAt); @@ -160,7 +123,7 @@ class hasTimes extends Base { let time = new Time(null, created, note, this, this.config); time.seconds = Time.parse(timeString, 8, 5, 4) * multiplier; - + // add to total time spent totalTimeSpent += time.seconds; @@ -221,6 +184,4 @@ class hasTimes extends Base { return promise; } -} - -export default hasTimes; +}; diff --git a/src/gtt-report.js b/src/reporting/commands/report.js similarity index 95% rename from src/gtt-report.js rename to src/reporting/commands/report.js index 81f39c6..206a8f1 100755 --- a/src/gtt-report.js +++ b/src/reporting/commands/report.js @@ -2,23 +2,19 @@ import _ from 'underscore'; import fs from 'fs'; import {Command} from 'commander'; import moment from 'moment'; -import Cli from './include/cli.js'; -import Config from './include/file-config.js'; -import Report from './models/report.js'; -import Owner from './models/owner.js'; -import ReportCollection from './models/reportCollection.js'; -import table from './output/table.js'; -import csv from './output/csv.js'; -import markdown from './output/markdown.js'; -import invoice from './output/invoice.js'; -import xlsx from './output/xlsx.js'; - +import Cli from '../../core/cli.js'; +import Config from '../../core/file-config.js'; +import Report from '../api/report.js'; +import Owner from '../../core/owner.js'; +import ReportCollection from '../api/reportCollection.js'; +// output backends are imported lazily so timekeeping commands never pull in +// the heavy deps (xlsx, swissqrbill, markdown-table, csv-string, cli-table) const Output = { - table, - csv, - markdown, - invoice, - xlsx + 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') }; // this collects options @@ -358,12 +354,13 @@ new Promise(resolve => { })) // make report - .then(() => new Promise(resolve => { + .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 (Output[config.get('output')])(config, master); + output = new module.default(config, master); output.make(); Cli.mark(); resolve(); diff --git a/src/output/base.js b/src/reporting/output/base.js similarity index 97% rename from src/output/base.js rename to src/reporting/output/base.js index 1cf9583..5be1c1f 100755 --- a/src/output/base.js +++ b/src/reporting/output/base.js @@ -9,9 +9,10 @@ const defaultFormats = { }; /** - * Base output + * Base output renderer: takes a report, aggregates it via calculate(), and + * exposes write/headline/toStdOut/toFile. Root of the output-format hierarchy. */ -class base { +class Output { /** * constructor * @param config @@ -58,7 +59,7 @@ class base { /** * add the given string * @param string - * @returns {base} + * @returns {Output} */ write(string) { this.out += this.formats.write(string); @@ -245,4 +246,4 @@ class base { } } -export default base; \ No newline at end of file +export default Output; \ No newline at end of file diff --git a/src/output/csv.js b/src/reporting/output/csv.js similarity index 98% rename from src/output/csv.js rename to src/reporting/output/csv.js index 31e6c0b..0a63e7c 100755 --- a/src/output/csv.js +++ b/src/reporting/output/csv.js @@ -2,12 +2,12 @@ import _ from 'underscore'; import fs from 'fs'; import path from 'path'; import Csv from 'csv-string'; -import Base from './base.js'; +import Output from './base.js'; /** * csv output */ -class csv extends Base { +class csv extends Output { makeStats() { let stats = [[], []]; diff --git a/src/output/invoice.js b/src/reporting/output/invoice.js similarity index 99% rename from src/output/invoice.js rename to src/reporting/output/invoice.js index 6ae2e70..88c37b1 100644 --- a/src/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import {markdownTable as Table} from 'markdown-table' -import Base from './base.js'; +import Output from './base.js'; import { SwissQRBill } from "swissqrbill/svg"; const format = { @@ -11,7 +11,7 @@ const format = { /** * invoice, code heavily based on markdown.js */ -class invoice extends Base { +class invoice extends Output { constructor(config, report) { super(config, report); this.format = format; diff --git a/src/output/markdown.js b/src/reporting/output/markdown.js similarity index 96% rename from src/output/markdown.js rename to src/reporting/output/markdown.js index f4a400e..3f510af 100755 --- a/src/output/markdown.js +++ b/src/reporting/output/markdown.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import {markdownTable as Table} from 'markdown-table' -import Base from './base.js'; +import Output from './base.js'; const format = { headline: h => `\n### ${h}\n`, @@ -10,7 +10,7 @@ const format = { /** * stdout table output */ -class markdown extends Base { +class markdown extends Output { constructor(config, report) { super(config, report); this.format = format; diff --git a/src/output/styles/highlight/default.css b/src/reporting/output/styles/highlight/default.css similarity index 100% rename from src/output/styles/highlight/default.css rename to src/reporting/output/styles/highlight/default.css diff --git a/src/output/styles/layout/default.css b/src/reporting/output/styles/layout/default.css similarity index 100% rename from src/output/styles/layout/default.css rename to src/reporting/output/styles/layout/default.css diff --git a/src/output/table.js b/src/reporting/output/table.js similarity index 98% rename from src/output/table.js rename to src/reporting/output/table.js index 628b288..eb77f7c 100755 --- a/src/output/table.js +++ b/src/reporting/output/table.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import Table from 'cli-table'; -import Base from './base.js'; +import Output from './base.js'; import Color from 'colors'; const format = { @@ -11,7 +11,7 @@ const format = { /** * stdout table output */ -class table extends Base { +class table extends Output { constructor(config, report) { super(config, report); this.format = format; diff --git a/src/output/xlsx.js b/src/reporting/output/xlsx.js similarity index 96% rename from src/output/xlsx.js rename to src/reporting/output/xlsx.js index 9a49f08..c942d06 100644 --- a/src/output/xlsx.js +++ b/src/reporting/output/xlsx.js @@ -2,13 +2,13 @@ import _ from 'underscore'; import fs from 'fs'; import path from 'path'; import XLSX from 'xlsx'; -import Base from './base.js'; -import Cli from './../include/cli.js'; +import Output from './base.js'; +import Cli from '../../core/cli.js'; /** * xlsx output */ -class xlsx extends Base { +class xlsx extends Output { makeStats() { let stats = [[], []]; diff --git a/src/timekeeping/api/issue.js b/src/timekeeping/api/issue.js new file mode 100644 index 0000000..bf96427 --- /dev/null +++ b/src/timekeeping/api/issue.js @@ -0,0 +1,43 @@ +import CoreIssue from '../../core/issue.js'; +import writable from './writable.js'; + +/** + * issue with timekeeping write operations: make() (get-or-create) and list(). + */ +class issue extends writable(CoreIssue) { + make(project, id, create = false) { + let promise; + + if (create) { + promise = this.post(`projects/${encodeURIComponent(project)}/issues`, {title: id}); + } else { + promise = this.get(`projects/${encodeURIComponent(project)}/issues/${id}`); + } + + promise.then(issue => { + this.data = issue.body; + return promise; + }); + + return promise; + } + + list(project, state, my) { + return new Promise((resolve, reject) => { + let promise; + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + if (project) { + promise = this.get(`projects/${encodeURIComponent(project)}/issues?${query}`); + } else { + promise = this.get(`issues/?${query}`); + } + promise.then(response => { + const issues = response.body.map(issue => new this.constructor(this.config, issue)) + resolve(issues) + }); + promise.catch(error => reject(error)) + }) + } +} + +export default issue; diff --git a/src/timekeeping/api/mergeRequest.js b/src/timekeeping/api/mergeRequest.js new file mode 100644 index 0000000..4021c3c --- /dev/null +++ b/src/timekeeping/api/mergeRequest.js @@ -0,0 +1,26 @@ +import CoreMergeRequest from '../../core/mergeRequest.js'; +import writable from './writable.js'; + +/** + * merge request with timekeeping write operation: make() (get-or-create). + */ +class mergeRequest extends writable(CoreMergeRequest) { + make(project, id, create = false) { + let promise; + + if (create) { + promise = this.post(`projects/${encodeURIComponent(project)}/merge_requests`, {title: id}); + } else { + promise = this.get(`projects/${encodeURIComponent(project)}/merge_requests/${id}`); + } + + promise.then(issue => { + this.data = issue.body; + return promise; + }); + + return promise; + } +} + +export default mergeRequest; diff --git a/src/timekeeping/api/writable.js b/src/timekeeping/api/writable.js new file mode 100644 index 0000000..4836830 --- /dev/null +++ b/src/timekeeping/api/writable.js @@ -0,0 +1,27 @@ +import Time from '../../core/time.js'; + +/** + * mixin: adds the timekeeping write operation createTime() — posts a + * "/spend" note to the resource's GitLab issue/merge request. + * @param Base a core issue/mergeRequest class + */ +export default Base => class extends Base { + /** + * create time + * @param time + * @returns {*} + */ + createTime(time, created_at, note) { + if(note === null || note === undefined) { + note = ''; + } + else { + note = '\n\n' + note; + } + var date = new Date(created_at); + var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); + return this.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { + body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt + note), + }); + } +}; diff --git a/src/gtt-cancel.js b/src/timekeeping/commands/cancel.js similarity index 88% rename from src/gtt-cancel.js rename to src/timekeeping/commands/cancel.js index cf0a8f7..6ad421e 100755 --- a/src/gtt-cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -1,9 +1,9 @@ import {Command} from 'commander'; import colors from 'colors'; import moment from 'moment'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; function cancel() { const cancel = new Command('cancel', 'cancel and discard active monitoring time') diff --git a/src/gtt-config.js b/src/timekeeping/commands/config.js similarity index 83% rename from src/gtt-config.js rename to src/timekeeping/commands/config.js index 882b7b9..dde13eb 100755 --- a/src/gtt-config.js +++ b/src/timekeeping/commands/config.js @@ -1,6 +1,6 @@ import {Command} from 'commander'; -import Config from './include/file-config.js'; -import Fs from './include/filesystem.js'; +import Config from '../../core/file-config.js'; +import Fs from '../../core/filesystem.js'; let config = new Config(process.cwd()); diff --git a/src/gtt-create.js b/src/timekeeping/commands/create.js similarity index 90% rename from src/gtt-create.js rename to src/timekeeping/commands/create.js index 7644e86..6f44cb9 100755 --- a/src/gtt-create.js +++ b/src/timekeeping/commands/create.js @@ -1,9 +1,9 @@ import colors from 'colors'; import moment from 'moment'; import {Command} from 'commander'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; function create() { diff --git a/src/gtt-delete.js b/src/timekeeping/commands/delete.js similarity index 83% rename from src/gtt-delete.js rename to src/timekeeping/commands/delete.js index 8e82c93..0fd0ba9 100755 --- a/src/gtt-delete.js +++ b/src/timekeeping/commands/delete.js @@ -1,8 +1,8 @@ import { Command } from 'commander'; -import Frame from './models/frame.js'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Fs from './include/filesystem.js'; +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'; function delCmd() { const delCmd = new Command('delete', 'delete time record by the given id') diff --git a/src/gtt-edit.js b/src/timekeeping/commands/edit.js similarity index 91% rename from src/gtt-edit.js rename to src/timekeeping/commands/edit.js index a14ea63..849b486 100755 --- a/src/gtt-edit.js +++ b/src/timekeeping/commands/edit.js @@ -1,10 +1,10 @@ import {Command} from 'commander'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Fs from './include/filesystem.js'; +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 './models/time.js'; -import Frame from './models/frame.js'; +import Time from '../../core/time.js'; +import Frame from '../storage/frame.js'; import select from '@inquirer/select'; diff --git a/src/gtt-list.js b/src/timekeeping/commands/list.js similarity index 90% rename from src/gtt-list.js rename to src/timekeeping/commands/list.js index 781c3c2..508ac24 100755 --- a/src/gtt-list.js +++ b/src/timekeeping/commands/list.js @@ -4,9 +4,9 @@ import moment from 'moment'; import Table from 'cli-table'; -import Config from './include/file-config.js'; -import cli from './include/cli.js'; -import Tasks from './include/tasks.js'; +import Config from '../../core/file-config.js'; +import cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; function list() { const list = new Command('list', 'list all open issues') diff --git a/src/gtt-log.js b/src/timekeeping/commands/log.js similarity index 94% rename from src/gtt-log.js rename to src/timekeeping/commands/log.js index a13764b..330fff9 100755 --- a/src/gtt-log.js +++ b/src/timekeeping/commands/log.js @@ -2,11 +2,11 @@ import _ from 'underscore'; import {Command} from 'commander'; import colors from 'colors'; import moment from 'moment-timezone'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Time from './models/time.js'; -import Tasks from './include/tasks.js'; -import mergeRequest from './models/mergeRequest.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Time from '../../core/time.js'; +import Tasks from '../storage/tasks.js'; +import mergeRequest from '../api/mergeRequest.js'; function log() { const log = new Command('log', 'log recorded time records') diff --git a/src/gtt-resume.js b/src/timekeeping/commands/resume.js similarity index 94% rename from src/gtt-resume.js rename to src/timekeeping/commands/resume.js index c4c5cac..8f2265a 100755 --- a/src/gtt-resume.js +++ b/src/timekeeping/commands/resume.js @@ -1,11 +1,11 @@ import { Command } from 'commander'; import colors from 'colors'; import moment from 'moment'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; -import Fs from './include/filesystem.js'; -import Frame from './models/frame.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; +import Fs from '../../core/filesystem.js'; +import Frame from '../storage/frame.js'; import select from '@inquirer/select'; const listSize = 30; diff --git a/src/gtt-start.js b/src/timekeeping/commands/start.js similarity index 92% rename from src/gtt-start.js rename to src/timekeeping/commands/start.js index 7a6ba33..b31c7da 100755 --- a/src/gtt-start.js +++ b/src/timekeeping/commands/start.js @@ -1,9 +1,9 @@ import colors from 'colors'; import moment from 'moment'; import {Command} from 'commander'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; function start() { diff --git a/src/gtt-status.js b/src/timekeeping/commands/status.js similarity index 91% rename from src/gtt-status.js rename to src/timekeeping/commands/status.js index 9949597..2715988 100755 --- a/src/gtt-status.js +++ b/src/timekeeping/commands/status.js @@ -1,9 +1,9 @@ import {Command} from 'commander'; import colors from 'colors'; import moment from 'moment'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; function status() { diff --git a/src/gtt-stop.js b/src/timekeeping/commands/stop.js similarity index 88% rename from src/gtt-stop.js rename to src/timekeeping/commands/stop.js index 2599b35..bd60b07 100755 --- a/src/gtt-stop.js +++ b/src/timekeeping/commands/stop.js @@ -1,9 +1,9 @@ import {Command} from 'commander'; import colors from 'colors'; import moment from 'moment'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; function stop() { diff --git a/src/gtt-sync.js b/src/timekeeping/commands/sync.js similarity index 88% rename from src/gtt-sync.js rename to src/timekeeping/commands/sync.js index 05757a2..88ea65e 100755 --- a/src/gtt-sync.js +++ b/src/timekeeping/commands/sync.js @@ -1,9 +1,9 @@ import moment from 'moment'; import {Command} from 'commander'; -import Config from './include/file-config.js'; -import Cli from './include/cli.js'; -import Tasks from './include/tasks.js'; -import Owner from './models/owner.js'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Tasks from '../storage/tasks.js'; +import Owner from '../../core/owner.js'; function sync() { const sync = new Command('sync', 'sync local time records to GitLab') diff --git a/src/models/baseFrame.js b/src/timekeeping/storage/baseFrame.js similarity index 98% rename from src/models/baseFrame.js rename to src/timekeeping/storage/baseFrame.js index 2c5f391..977610a 100755 --- a/src/models/baseFrame.js +++ b/src/timekeeping/storage/baseFrame.js @@ -1,4 +1,4 @@ -import Config from '../include/config.js'; +import Config from '../../core/config.js'; import moment from 'moment-timezone'; import Hashids from 'hashids'; const hashids = new Hashids(); diff --git a/src/models/frame.js b/src/timekeeping/storage/frame.js similarity index 100% rename from src/models/frame.js rename to src/timekeeping/storage/frame.js diff --git a/src/models/frameCollection.js b/src/timekeeping/storage/frameCollection.js similarity index 88% rename from src/models/frameCollection.js rename to src/timekeeping/storage/frameCollection.js index 3c6646c..f12dddb 100755 --- a/src/models/frameCollection.js +++ b/src/timekeeping/storage/frameCollection.js @@ -1,8 +1,8 @@ -import Base from './base.js'; +import GitlabClient from '../../core/gitlab-client.js'; import Frame from './frame.js'; -import Fs from './../include/filesystem.js'; +import Fs from '../../core/filesystem.js'; -class frameCollection extends Base { +class frameCollection extends GitlabClient { constructor(config) { super(config); diff --git a/src/include/tasks.js b/src/timekeeping/storage/tasks.js similarity index 96% rename from src/include/tasks.js rename to src/timekeeping/storage/tasks.js index faf9d49..a254507 100755 --- a/src/include/tasks.js +++ b/src/timekeeping/storage/tasks.js @@ -1,10 +1,10 @@ import _ from 'underscore'; import moment from 'moment'; -import Fs from './filesystem.js'; -import Frame from './../models/frame.js'; -import Issue from './../models/issue.js'; -import MergeRequest from './../models/mergeRequest.js'; -import FrameCollection from './../models/frameCollection.js'; +import Fs from '../../core/filesystem.js'; +import Frame from './frame.js'; +import Issue from '../api/issue.js'; +import MergeRequest from '../api/mergeRequest.js'; +import FrameCollection from './frameCollection.js'; const classes = { issue: Issue, From e3eca0a173354feccd4ab00739d8c06c62871fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:48:10 +0200 Subject: [PATCH 02/72] switch gitlab API (reporting to GraphQL) (#40) refactor: - compose GitlabClient instead of inheriting it - unify issue and mergeRequest core models into task - remove obsolete code Co-authored-by: ndu --- spec/models/time.title.spec.js | 9 +- src/core/gitlab-client.js | 22 +-- src/core/mergeRequest.js | 88 ----------- src/core/owner.js | 20 +-- src/core/parallel.js | 19 +++ src/core/resource.js | 29 ---- src/core/{issue.js => task.js} | 24 +-- src/reporting/api/issue.js | 10 +- src/reporting/api/mergeRequest.js | 10 +- src/reporting/api/project.js | 14 +- src/reporting/api/report.js | 30 ++-- src/reporting/api/reportCollection.js | 8 +- src/reporting/api/reportable.js | 141 +++--------------- src/reporting/commands/report.js | 17 ++- src/reporting/output/base.js | 4 - src/reporting/output/invoice.js | 16 -- src/timekeeping/api/issue.js | 51 +++---- src/timekeeping/api/mergeRequest.js | 24 +-- src/timekeeping/api/writable.js | 36 ++++- src/timekeeping/commands/cancel.js | 6 +- src/timekeeping/commands/create.js | 6 +- src/timekeeping/commands/list.js | 6 +- src/timekeeping/commands/log.js | 6 +- src/timekeeping/commands/resume.js | 12 +- src/timekeeping/commands/start.js | 6 +- src/timekeeping/commands/status.js | 6 +- src/timekeeping/commands/stop.js | 6 +- src/timekeeping/commands/sync.js | 20 +-- src/timekeeping/storage/baseFrame.js | 117 --------------- src/timekeeping/storage/frame.js | 101 ++++++++++++- src/timekeeping/storage/frameCollection.js | 8 +- .../{storage/tasks.js => timekeeper.js} | 16 +- 32 files changed, 329 insertions(+), 559 deletions(-) delete mode 100755 src/core/mergeRequest.js create mode 100644 src/core/parallel.js delete mode 100755 src/core/resource.js rename src/core/{issue.js => task.js} (71%) mode change 100755 => 100644 delete mode 100755 src/timekeeping/storage/baseFrame.js rename src/timekeeping/{storage/tasks.js => timekeeper.js} (95%) diff --git a/spec/models/time.title.spec.js b/spec/models/time.title.spec.js index 98ccac7..77a46ae 100644 --- a/spec/models/time.title.spec.js +++ b/spec/models/time.title.spec.js @@ -2,14 +2,13 @@ import moment from 'moment'; import Config from '../../src/core/file-config.js'; import Time from '../../src/core/time.js'; -import issue from '../../src/core/issue.js'; -import mergeRequest from '../../src/core/mergeRequest.js'; +import task from '../../src/core/task.js'; import { expect } from 'chai'; describe('time class', () => { it('Returns title of parent Issue', () => { const config = new Config(process.cwd()); - const parent = new issue(config, {title: "Test title"}) + const parent = new task(config, {title: "Test title"}, undefined, 'issues') const time = new Time('1h', moment(), {}, parent, config); expect(time.title).to.be.equal("Test title"); @@ -17,7 +16,7 @@ describe('time class', () => { it('Returns title of parent MergeRequest', () => { const config = new Config(process.cwd()); - const parent = new mergeRequest(config, {title: "Test title"}) + const parent = new task(config, {title: "Test title"}, undefined, 'merge_requests') const time = new Time('1h', moment(), {}, parent, config); expect(time.title).to.be.equal("Test title"); @@ -25,7 +24,7 @@ describe('time class', () => { it('Returns Null for missed title or parent', () => { const config = new Config(process.cwd()); - const parent = new mergeRequest(config, {}); + const parent = new task(config, {}, undefined, 'merge_requests'); let time; time = new Time('1h', moment(), {}, parent, config); expect(time.title).to.be.equal(null); diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 350df16..81ab28d 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -1,6 +1,6 @@ -import async from 'async'; import crypto from 'crypto'; import throttleFactory from 'throttled-queue'; +import parallel from './parallel.js'; /** * GitLab REST/GraphQL client: owns the request throttle, single/parallel @@ -153,22 +153,6 @@ class GitlabClient { }); } - /** - * perform the given worker function on the given tasks in parallel - * @param tasks - * @param worker - * @param runners - * @returns {Promise} - */ - parallel(tasks, worker, runners = this._parallel) { - return new Promise((resolve, reject) => { - async.eachLimit(Array.from(tasks), runners, worker, error => { - if (error) return reject(error); - resolve(); - }); - }); - } - /** * make multiple get requests by the given tasks and apply the * data to the given set @@ -178,12 +162,12 @@ class GitlabClient { * @returns {Promise} */ getParallel(tasks, collect = [], runners = this._parallel) { - return this.parallel(tasks, (task, done) => { + 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)); - }, runners); + }, this.config, runners); } /** diff --git a/src/core/mergeRequest.js b/src/core/mergeRequest.js deleted file mode 100755 index ce24b42..0000000 --- a/src/core/mergeRequest.js +++ /dev/null @@ -1,88 +0,0 @@ -import _ from 'underscore'; -import resource from './resource.js'; - -/** - * merge request model — shared data/getters. Write ops (make/createTime) live in - * timekeeping/api/mergeRequest.js; read/aggregation in reporting/api/mergeRequest.js. - */ -class mergeRequest extends resource { - constructor(config, data = {}) { - super(config); - this.data = data; - } - - /* - * properties - */ - get iid() { - return this.data.iid; - } - - get id() { - return this.data.id; - } - - get title() { - return this.data.title; - } - - get project_id() { - return this.data.project_id; - } - - get description() { - return this.data.description; - } - - get labels() { - let labels = _.difference(this.data.labels, this.config.get('excludeLabels')); - let include = this.config.get('includeLabels'); - return include.length > 0 ? _.intersection(labels, include) : labels; - } - - get milestone() { - return this.data.milestone ? this.data.milestone.title : null; - } - - get assignee() { - return this.data.assignee ? this.data.assignee.username : null; - } - - get author() { - return this.data.author.username; - } - - get updated_at() { - return moment(this.data.updated_at); - } - - get created_at() { - return moment(this.data.created_at); - } - - get state() { - return this.data.state; - } - - get spent() { - return this.config.toHumanReadable(this.timeSpent, this._type); - } - - get total_spent() { - return this.stats ? this.config.toHumanReadable(this.stats.total_time_spent, this._type) : null; - } - - get total_estimate() { - return this.stats ? this.config.toHumanReadable(this.stats.time_estimate, this._type) : null; - } - - get _type() { - return 'merge_requests'; - } - - get _typeSingular() { - return 'Merge Request'; - } -} - -export default mergeRequest; \ No newline at end of file diff --git a/src/core/owner.js b/src/core/owner.js index 9f16cb7..ee370f6 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -1,12 +1,14 @@ import _ from 'underscore'; import GitlabClient from './gitlab-client.js'; +import parallel from './parallel.js'; /** * owner model */ -class owner extends GitlabClient { - constructor(config) { - super(config); +class owner { + constructor(config, client = new GitlabClient(config)) { + this.config = config; + this.client = client; this.projects = []; this.groups = []; this.users = []; @@ -20,7 +22,7 @@ class owner extends GitlabClient { if (!this.config.get('_checkToken')) return new Promise(r => r()); return new Promise((resolve, reject) => { - this.get('broadcast_messages') + this.client.get('broadcast_messages') .then(() => resolve()) .catch(e => { if (e.statusCode === 403) resolve(); @@ -35,7 +37,7 @@ class owner extends GitlabClient { */ getGroup() { return new Promise((resolve, reject) => { - this.get(`groups`) + this.client.get(`groups`) .then(groups => { if (groups.body.length === 0) return reject('Group not found'); groups = groups.body; @@ -55,7 +57,7 @@ class owner extends GitlabClient { */ getSubGroups() { return new Promise((resolve, reject) => { - this.get(`groups`) + this.client.get(`groups`) .then(groups => { if (groups.body.length === 0) return resolve(); @@ -119,14 +121,14 @@ class owner extends GitlabClient { * @returns {Promise} */ getProjectsByGroup() { - return this.parallel(this.groups, (group, done) => { - this.all(`groups/${group.id}/projects`) + return parallel(this.groups, (group, done) => { + this.client.all(`groups/${group.id}/projects`) .then(projects => { this.projects = projects; done(); }) .catch(e => done(e)); - }); + }, this.config); } } diff --git a/src/core/parallel.js b/src/core/parallel.js new file mode 100644 index 0000000..2afb4d0 --- /dev/null +++ b/src/core/parallel.js @@ -0,0 +1,19 @@ +import async from 'async'; + +/** + * run the given async worker over the given tasks with a bounded + * number of concurrent runners. + * @param tasks iterable of work items + * @param worker (task, done) => void — node-style async callback per item + * @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(); + }); + }); +} diff --git a/src/core/resource.js b/src/core/resource.js deleted file mode 100755 index 9d967e1..0000000 --- a/src/core/resource.js +++ /dev/null @@ -1,29 +0,0 @@ -import GitlabClient from './gitlab-client.js'; - -/** - * shared base for models that have times (issues, merge requests). - * Holds only what both timekeeping and reporting need: the notes read. - * Timekeeping write methods live in timekeeping/api/writable.js, - * reporting read/aggregation lives in reporting/api/reportable.js. - */ -class resource extends GitlabClient { - constructor(config) { - super(config); - this.times = []; - this.timesWarnings = []; - this.days = {}; - } - - /** - * set notes - * @returns {Promise} - */ - getNotes() { - let promise = this.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); - promise.then(notes => this.notes = notes); - - return promise; - } -} - -export default resource; diff --git a/src/core/issue.js b/src/core/task.js old mode 100755 new mode 100644 similarity index 71% rename from src/core/issue.js rename to src/core/task.js index e9c8f88..264b364 --- a/src/core/issue.js +++ b/src/core/task.js @@ -1,15 +1,21 @@ import _ from 'underscore'; import moment from 'moment'; -import resource from './resource.js'; +import GitlabClient from './gitlab-client.js'; /** - * issue model — shared data/getters. Write ops (make/list/createTime) live in - * timekeeping/api/issue.js; read/aggregation in reporting/api/issue.js. + * task model — shared data/getters for issues and merge requests, which differ + * only by their GitLab resource type. Write ops (make/createTime) and the + * list() query live in timekeeping/api/*; read/aggregation in reporting/api/*. + * @param type the GitLab resource type: 'issues' or 'merge_requests' */ -class issue extends resource { - constructor(config, data = {}) { - super(config); +class task { + constructor(config, data = {}, client = new GitlabClient(config), type) { + this.config = config; + this.client = client; + this.times = []; + this.days = {}; this.data = data; + this.type = type; } /* @@ -86,12 +92,12 @@ class issue extends resource { } get _type() { - return 'issues'; + return this.type; } get _typeSingular() { - return 'Issue'; + return this.type === 'merge_requests' ? 'Merge Request' : 'Issue'; } } -export default issue; +export default task; diff --git a/src/reporting/api/issue.js b/src/reporting/api/issue.js index c8d4bb6..830e41a 100644 --- a/src/reporting/api/issue.js +++ b/src/reporting/api/issue.js @@ -1,9 +1,13 @@ -import CoreIssue from '../../core/issue.js'; +import CoreTask from '../../core/task.js'; import reportable from './reportable.js'; /** - * issue with reporting read/aggregation (getStats, getTimes, recordTimelogs). + * issue with reporting read/aggregation (getStats, recordTimelogs). */ -class issue extends reportable(CoreIssue) {} +class issue extends reportable(CoreTask) { + constructor(config, data, client) { + super(config, data, client, 'issues'); + } +} export default issue; diff --git a/src/reporting/api/mergeRequest.js b/src/reporting/api/mergeRequest.js index 66f1b61..a4e01d6 100644 --- a/src/reporting/api/mergeRequest.js +++ b/src/reporting/api/mergeRequest.js @@ -1,9 +1,13 @@ -import CoreMergeRequest from '../../core/mergeRequest.js'; +import CoreTask from '../../core/task.js'; import reportable from './reportable.js'; /** - * merge request with reporting read/aggregation (getStats, getTimes, recordTimelogs). + * merge request with reporting read/aggregation (getStats, recordTimelogs). */ -class mergeRequest extends reportable(CoreMergeRequest) {} +class mergeRequest extends reportable(CoreTask) { + constructor(config, data, client) { + super(config, data, client, 'merge_requests'); + } +} export default mergeRequest; diff --git a/src/reporting/api/project.js b/src/reporting/api/project.js index 9c60ba4..b0b2507 100755 --- a/src/reporting/api/project.js +++ b/src/reporting/api/project.js @@ -3,14 +3,16 @@ import GitlabClient from '../../core/gitlab-client.js'; /** * project model */ -class project extends GitlabClient { +class project { /** * construct * @param config * @param data + * @param client */ - constructor(config, data) { - super(config); + constructor(config, data, client = new GitlabClient(config)) { + this.config = config; + this.client = client; this.data = data; this.projectMembers = data.members ? data.members : []; } @@ -20,7 +22,7 @@ class project extends GitlabClient { * @param name */ make(name) { - let promise = this.get(`projects/${encodeURIComponent(name)}`); + let promise = this.client.get(`projects/${encodeURIComponent(name)}`); promise.then(project => this.data = project.body); return promise; @@ -32,7 +34,7 @@ class project extends GitlabClient { */ members() { return new Promise((resolve, reject) => { - this.get(`projects/${this.id}/members`) + this.client.get(`projects/${this.id}/members`) .then(response => { this.projectMembers = this.projectMembers.concat(response.body); return new Promise(r => r()); @@ -40,7 +42,7 @@ class project extends GitlabClient { .then(() => { if (!this.data.namespace || !this.data.namespace.kind || this.data.namespace.kind !== "group") return resolve(); - this.get(`groups/${this.data.namespace.id}/members`) + this.client.get(`groups/${this.data.namespace.id}/members`) .then(response => { this.projectMembers = this.projectMembers.concat(response.body); resolve(); diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 6f487ea..51a3d6c 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import moment from 'moment'; import GitlabClient from '../../core/gitlab-client.js'; +import parallel from '../../core/parallel.js'; import Issue from './issue.js'; import MergeRequest from './mergeRequest.js'; import Project from './project.js'; @@ -8,14 +9,15 @@ import Project from './project.js'; /** * report model */ -class report extends GitlabClient { +class report { /** * constructor. * @param config * @param project */ - constructor(config, project) { - super(config); + constructor(config, project, client = new GitlabClient(config)) { + this.config = config; + this.client = client; this.projects = {}; this.setProject(project); @@ -60,7 +62,7 @@ class report extends GitlabClient { if (!project) return; this.projects[project.id] = project.path_with_namespace; - this.project = new Project(this.config, project) + this.project = new Project(this.config, project, this.client) } /** @@ -68,7 +70,7 @@ class report extends GitlabClient { * @returns {Promise} */ getProject() { - let promise = this.get(`projects/${encodeURIComponent(this.config.get('project'))}`); + let promise = this.client.get(`projects/${encodeURIComponent(this.config.get('project'))}`); promise.then(project => this.setProject(project.body)); return promise; @@ -79,7 +81,7 @@ class report extends GitlabClient { * @returns {Promise} */ getMergeRequests() { - let promise = this.all(`projects/${this.project.id}/merge_requests${this.params()}`); + let promise = this.client.all(`projects/${this.project.id}/merge_requests${this.params()}`); let excludes = this.config.get('excludeByLabels'); promise.then(mergeRequests => this.mergeRequests = mergeRequests.filter(mr => ( (!excludes || excludes.filter(l=>mr.labels.includes(l)).length==0) // keep all merge requests not including a exclude label @@ -93,7 +95,7 @@ class report extends GitlabClient { * @returns {Promise} */ getIssues() { - let promise = this.all(`projects/${this.project.id}/issues${this.params()}`); + let promise = this.client.all(`projects/${this.project.id}/issues${this.params()}`); let excludes = this.config.get('excludeByLabels'); promise.then(issues => this.issues = issues.filter(issue => ( issue.moved_to_id == null && // filter moved issues in any case @@ -172,7 +174,7 @@ class report extends GitlabClient { } }; - let promise = this.graphQL(request); + let promise = this.client.graphQL(request); promise.then(response => { if (response.body.errors) { this.timelogs = []; @@ -232,9 +234,9 @@ class report extends GitlabClient { process(input, model, advance = false) { let collect = []; - let promise = this.parallel(this[input], (data, done) => { + let promise = parallel(this[input], (data, done) => { - let item = new model(this.config, data); + let item = new model(this.config, data, this.client); item.project_namespace = this.projects[item.project_id]; item.recordTimelogs(this.timelogs.filter( @@ -242,11 +244,7 @@ class report extends GitlabClient { timelog[input].iid == data.iid && timelog[input].projectId == data.project_id)); - item.getNotes() - .then(() => item.getTimes()) - .catch(error => done(error)) - .catch(error => done(error)) - .then(() => item.getStats()) + item.getStats() .catch(error => done(error)) .then(() => { if (this.config.get('showWithoutTimes') || item.times.length > 0) { @@ -260,7 +258,7 @@ class report extends GitlabClient { // collect items, query times & stats collect.push(); - }); + }, this.config); promise.then(() => this[input] = this.filter(collect)); return promise; diff --git a/src/reporting/api/reportCollection.js b/src/reporting/api/reportCollection.js index aa352a5..d23bab3 100755 --- a/src/reporting/api/reportCollection.js +++ b/src/reporting/api/reportCollection.js @@ -1,15 +1,15 @@ -import GitlabClient from '../../core/gitlab-client.js'; +import parallel from '../../core/parallel.js'; let projlist = []; -class reportCollection extends GitlabClient { +class reportCollection { constructor(config) { - super(config); + this.config = config; this.reports = []; } forEach(iterator) { - return this.parallel(this.reports, (report, done) => iterator(report, done)); + return parallel(this.reports, (report, done) => iterator(report, done), this.config); } push(report) { diff --git a/src/reporting/api/reportable.js b/src/reporting/api/reportable.js index 486d793..af7a93c 100644 --- a/src/reporting/api/reportable.js +++ b/src/reporting/api/reportable.js @@ -3,11 +3,6 @@ import moment from 'moment'; import Time from '../../core/time.js'; import DayReport from './dayReport.js'; -const regex = /added (.*) of time spent(?: at ([0-9-]*))?/i; -const subRegex = /subtracted (.*) of time spent(?: at ([0-9-]*))?/i; -const delRegex = /deleted (.*) of spent time(?: from ([0-9-]*))?/i; -const removeRegex = /Removed time spent/i; - /** * mixin: adds reporting read/aggregation to a core issue/mergeRequest class. * @param Base a core issue/mergeRequest class @@ -18,7 +13,7 @@ export default Base => class extends Base { * @returns {Promise} */ getStats() { - let promise = this.get(`projects/${this.data.project_id}/${this._type}/${this.iid}/time_stats`); + let promise = this.client.get(`projects/${this.data.project_id}/${this._type}/${this.iid}/time_stats`); promise.then(response => this.stats = response.body); return promise; @@ -51,7 +46,10 @@ export default Base => class extends Base { let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); - + let times = [], + timeSpent = 0, + timeUsers = {}, + timeFormat = this.config.get('timeFormat', this._type); timelogs.forEach( (timelog) => { @@ -65,123 +63,28 @@ export default Base => class extends Base { this.days[dateGrp].addNote(timelog.note.body); } this.days[dateGrp].addSpent(timelog.timeSpent); - }); - } - - /** - * set times (call set notes first) - * @returns {Promise} - */ - getTimes() { - let times = [], - timesWarnings = [], - timeSpent = 0, - totalTimeSpent = 0, - timeUsers = {}, - timeFormat = this.config.get('timeFormat', this._type); - - // sort by created at - this.notes.sort((a, b) => { - if (a.created_at === b.created_at) return 0; - return moment(a.created_at).isBefore(b.created_at) ? -1 : 1; - }); - - let promise = this.parallel(this.notes, (note, done) => { - let created = moment(note.created_at), match, subMatch, delMatch; - - if ( // - // filter out user notes - !note.system || - // filter out notes that are no time things - !(match = regex.exec(note.body)) && !(subMatch = subRegex.exec(note.body)) && !removeRegex.exec(note.body) && !(delMatch = delRegex.exec(note.body)) - ) return done(); - - // change created date when explicitly defined - if(match && match[2]) created = moment(match[2]); - if(subMatch && subMatch[2]) created = moment(subMatch[2]); - if(delMatch && delMatch[2]) created = moment(delMatch[2]); - - // create a time string and a time object - let timeString = null; - let multiplier = 1; - if(match) { - timeString = match[1]; - } - else if(subMatch) { - timeString = subMatch[1]; - multiplier = -1; - } - else if(delMatch){ - timeString = delMatch[1]; - multiplier = -1;; - } - else { - // Removed time spent -> remove all - timeString = Time.toHumanReadable(timeSpent); - multiplier = -1; - } - - let time = new Time(null, created, note, this, this.config); - time.seconds = Time.parse(timeString, 8, 5, 4) * multiplier; - - // add to total time spent - totalTimeSpent += time.seconds; + let time = new Time(null, spentAt, { + author: {username: timelog.user.username}, + created_at: timelog.spentAt, + noteable_type: this._typeSingular + }, this, this.config); + time.seconds = timelog.timeSpent; + time.project_namespace = this.project_namespace; - if ( // - // only include times by the configured user - (this.config.get('user') && this.config.get('user') !== note.author.username) || - // filter out times that are not in the given time frame - !(created.isSameOrAfter(moment(this.config.get('from'))) && created.isSameOrBefore(moment(this.config.get('to')))) - ) return done(); + // only include times by the configured user + if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; - if (!timeUsers[note.author.username]) timeUsers[note.author.username] = 0; + if (!timeUsers[timelog.user.username]) timeUsers[timelog.user.username] = 0; - // add to time spent & add to user specific time spent - timeSpent += time.seconds; - timeUsers[note.author.username] += time.seconds; + timeSpent += time.seconds; + timeUsers[timelog.user.username] += time.seconds; - time.project_namespace = this.project_namespace; - times.push(time); - - done(); - }); - - promise = promise.then(() => new Promise(resolve => { - let created = moment(this.data.created_at); - - if ( // - // skip if description parsing is disabled - this.config.get('_skipDescriptionParsing') || - // or time stats are not available - !this.data.time_stats || !this.data.time_stats.total_time_spent || - // or the total time matches - !this.data.time_stats || - totalTimeSpent === this.data.time_stats.total_time_spent || - // or the user is filtered out - (this.config.get('user') && this.config.get('user') !== this.data.author.username) || - // or the issue is not within the given time frame - !(created.isSameOrAfter(moment(this.config.get('from'))) && created.isSameOrBefore(moment(this.config.get('to')))) - ) return resolve(); - - // warn about difference, but do not correct as gitlab API - // stats forget the times after an issue is moved to another project. - let difference = this.data.time_stats.total_time_spent - totalTimeSpent, - note = Object.assign({noteable_type: this._typeSingular}, this.data); - note.timeWarning = {}; - note.timeWarning['stats'] = this.data.time_stats.total_time_spent; - note.timeWarning['notes'] = totalTimeSpent; - timesWarnings.push(new Time(Time.toHumanReadable(difference, this.config.get('hoursPerDay')), null, note, this, this.config)); - resolve(); - })); - - promise.then(() => { - _.each(timeUsers, (time, name) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); - this.timeSpent = timeSpent; - this.times = times; - this.timesWarnings = timesWarnings; - }); + times.push(time); + }); - return promise; + _.each(timeUsers, (time, name) => 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 206a8f1..d8d89cd 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -7,6 +7,8 @@ import Config from '../../core/file-config.js'; import Report from '../api/report.js'; import Owner from '../../core/owner.js'; 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) const Output = { @@ -157,8 +159,9 @@ if(config.get('invoicePositionExtraText').length != config.get('invoicePositionE } // create stuff -let reports = new ReportCollection(config), - master = new Report(config), +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')], output; @@ -206,16 +209,16 @@ new Promise(resolve => { // get project(s) .then(() => new Promise((resolve, reject) => { Cli.list(`${Cli.look} Resolving "${projectLabels}"`); - let owner = new Owner(config); + let owner = new Owner(config, client); owner.authorized() .catch(e => Cli.x(`Invalid access token!`, e)) - .then(() => owner.parallel(projects, (project, done) => { + .then(() => parallel(projects, (project, done) => { config.set('project', project); switch (config.get('type')) { case 'project': - let report = new Report(config); + let report = new Report(config, undefined, client); report.getProject() .then(() => { reports.push(report); @@ -232,13 +235,13 @@ new Promise(resolve => { }) .then(() => owner.getProjectsByGroup() .then(() => { - owner.projects.forEach(project => reports.push(new Report(config, project))); + owner.projects.forEach(project => reports.push(new Report(config, project, client))); done(); })) .catch(e => done(e)); break; } - }, 1)) + }, config, 1)) .catch(e => reject(e)) .then(() => { config.set('project', projects); diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index 5be1c1f..5507b5a 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -114,7 +114,6 @@ class Output { let users = {}; let projects = {}; let times = []; - let timesWarnings = []; let days = {}; let daysMoment = {}; let daysNew = {}; @@ -182,8 +181,6 @@ class Output { } times.push(time); }); - issue.timesWarnings.forEach(warning => timesWarnings.push(warning)); - totalEstimate += parseInt(issue.stats.time_estimate); totalSpent += parseInt(issue.stats.total_time_spent); }); @@ -218,7 +215,6 @@ class Output { this.spentFree = spentFree; this.spentHalfPrice = spentHalfPrice; this.totalSpent = totalSpent; - this.timesWarnings = timesWarnings; this.daysNew = daysNew; } diff --git a/src/reporting/output/invoice.js b/src/reporting/output/invoice.js index 88c37b1..d2ea813 100644 --- a/src/reporting/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -265,22 +265,6 @@ table th:nth-of-type(3) { width: 10%; } `); this.write(Table(timesNew, { align: ['l', 'l', 'r'] })); - - - - // warnings - let warnings = ''; - - this.timesWarnings.forEach( warning => { - let stats = this.config.toHumanReadable(warning.data.timeWarning.stats, 'stats'); - let notes = this.config.toHumanReadable(warning.data.timeWarning.notes, 'stats'); - warnings += `\n* ${warning.data.iid} ${warning.data.title}: Difference between stats and notes of ${warning.time}.`; - warnings += `
Stats: ${stats}, Notes: ${notes}` - }); - if(warnings != '') { - this.warningHeadline('Warnings'); - this.warning(warnings+'\n'); - } } } diff --git a/src/timekeeping/api/issue.js b/src/timekeeping/api/issue.js index bf96427..6f762d6 100644 --- a/src/timekeeping/api/issue.js +++ b/src/timekeeping/api/issue.js @@ -1,42 +1,29 @@ -import CoreIssue from '../../core/issue.js'; +import CoreTask from '../../core/task.js'; +import GitlabClient from '../../core/gitlab-client.js'; import writable from './writable.js'; /** - * issue with timekeeping write operations: make() (get-or-create) and list(). + * issue with timekeeping write operations (make/createTime via the writable + * mixin). The collection-level list() query is a static — it needs no + * instance state. */ -class issue extends writable(CoreIssue) { - make(project, id, create = false) { - let promise; - - if (create) { - promise = this.post(`projects/${encodeURIComponent(project)}/issues`, {title: id}); - } else { - promise = this.get(`projects/${encodeURIComponent(project)}/issues/${id}`); - } - - promise.then(issue => { - this.data = issue.body; - return promise; - }); - - return promise; +class issue extends writable(CoreTask) { + constructor(config, data, client) { + super(config, data, client, 'issues'); } - list(project, state, my) { - return new Promise((resolve, reject) => { - let promise; + /** + * list issues, either of a single project or across all projects + * @returns {Promise} resolving to an array of issue instances + */ + static list(config, project, state, my, client = new GitlabClient(config)) { const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; - if (project) { - promise = this.get(`projects/${encodeURIComponent(project)}/issues?${query}`); - } else { - promise = this.get(`issues/?${query}`); - } - promise.then(response => { - const issues = response.body.map(issue => new this.constructor(this.config, issue)) - resolve(issues) - }); - promise.catch(error => reject(error)) - }) + const path = project + ? `projects/${encodeURIComponent(project)}/issues?${query}` + : `issues/?${query}`; + + return client.get(path) + .then(response => response.body.map(data => new this(config, data, client))); } } diff --git a/src/timekeeping/api/mergeRequest.js b/src/timekeeping/api/mergeRequest.js index 4021c3c..17ae63b 100644 --- a/src/timekeeping/api/mergeRequest.js +++ b/src/timekeeping/api/mergeRequest.js @@ -1,25 +1,13 @@ -import CoreMergeRequest from '../../core/mergeRequest.js'; +import CoreTask from '../../core/task.js'; import writable from './writable.js'; /** - * merge request with timekeeping write operation: make() (get-or-create). + * 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(CoreMergeRequest) { - make(project, id, create = false) { - let promise; - - if (create) { - promise = this.post(`projects/${encodeURIComponent(project)}/merge_requests`, {title: id}); - } else { - promise = this.get(`projects/${encodeURIComponent(project)}/merge_requests/${id}`); - } - - promise.then(issue => { - this.data = issue.body; - return promise; - }); - - return promise; +class mergeRequest extends writable(CoreTask) { + constructor(config, data, client) { + super(config, data, client, 'merge_requests'); } } diff --git a/src/timekeeping/api/writable.js b/src/timekeeping/api/writable.js index 4836830..9eb8f65 100644 --- a/src/timekeeping/api/writable.js +++ b/src/timekeeping/api/writable.js @@ -1,11 +1,41 @@ import Time from '../../core/time.js'; /** - * mixin: adds the timekeeping write operation createTime() — posts a - * "/spend" note to the resource's GitLab issue/merge request. + * mixin: adds timekeeping write operations to a core issue/mergeRequest class: + * make() (get-or-create the resource) and createTime() (post a "/spend" note). + * The resource path segment is taken from the core class' _type getter. * @param Base a core issue/mergeRequest class */ export default Base => class extends Base { + /** + * get-or-create the resource on GitLab and populate this.data + * @param project + * @param id + * @param create create the resource instead of fetching it + * @returns {Promise} + */ + make(project, id, create = false) { + let promise = create + ? this.client.post(`projects/${encodeURIComponent(project)}/${this._type}`, {title: id}) + : this.client.get(`projects/${encodeURIComponent(project)}/${this._type}/${id}`); + + return promise.then(response => { + this.data = response.body; + return this; + }); + } + + /** + * set notes + * @returns {Promise} + */ + getNotes() { + let promise = this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); + promise.then(notes => this.notes = notes); + + return promise; + } + /** * create time * @param time @@ -20,7 +50,7 @@ export default Base => class extends Base { } var date = new Date(created_at); var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); - return this.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { + return this.client.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt + note), }); } diff --git a/src/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index 6ad421e..253a2d4 100755 --- a/src/timekeeping/commands/cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -3,7 +3,7 @@ import colors from 'colors'; import moment from 'moment'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; function cancel() { const cancel = new Command('cancel', 'cancel and discard active monitoring time') @@ -13,9 +13,9 @@ function cancel() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()); -let tasks = new Tasks(config); +let timekeeper = new Timekeeper(config); -tasks.cancel() +timekeeper.cancel() .then(frames => { frames.forEach(frame => { if(!frame.resource.new) diff --git a/src/timekeeping/commands/create.js b/src/timekeeping/commands/create.js index 6f44cb9..cbc3541 100755 --- a/src/timekeeping/commands/create.js +++ b/src/timekeeping/commands/create.js @@ -3,7 +3,7 @@ import moment from 'moment'; import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; function create() { @@ -16,7 +16,7 @@ function create() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()), - tasks = new Tasks(config), + timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', title = program.args.length === 1 ? program.args[0] : program.args[1], project = program.args.length === 2 ? program.args[0] : null; @@ -27,7 +27,7 @@ if (program.args.length < 2 && !config.get('project')) if (!title) Cli.error('Wrong or missing title'); -tasks.start(project, type, 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}`)) .catch(error => Cli.error(error)); } diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index 508ac24..5ea11de 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -6,7 +6,7 @@ import Table from 'cli-table'; import Config from '../../core/file-config.js'; import cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; function list() { const list = new Command('list', 'list all open issues') @@ -17,11 +17,11 @@ function list() { .action((aproject, options, program) => { let config = new Config(process.cwd()), - tasks = new Tasks(config), + timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', project = program.args[0]; -tasks.list(project, program.opts().closed ? 'closed' : 'opened', program.opts().my) +timekeeper.list(project, program.opts().closed ? 'closed' : 'opened', program.opts().my) .then(issues => { let table = new Table({ style : {compact : true, 'padding-left' : 1} diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index 330fff9..f4c7ae9 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -5,7 +5,7 @@ import moment from 'moment-timezone'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Time from '../../core/time.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; import mergeRequest from '../api/mergeRequest.js'; function log() { @@ -19,7 +19,7 @@ function log() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()).set('hoursPerDay', program.opts().hours_per_day), - tasks = new Tasks(config), + timekeeper = new Timekeeper(config), timeFormat = config.set('timeFormat', program.opts().time_format).get('timeFormat', 'log'); function toHumanReadable(input) { @@ -79,7 +79,7 @@ const logCli = (frames, times) => { const log = program.opts().csv? logCSV : logCli; -tasks.log() +timekeeper.log() .then(({frames, times}) => log(frames, times)) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index 8f2265a..9751352 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -3,7 +3,7 @@ import colors from 'colors'; import moment from 'moment'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; import Fs from '../../core/filesystem.js'; import Frame from '../storage/frame.js'; import select from '@inquirer/select'; @@ -17,8 +17,8 @@ function column(str, n) { return str.padEnd(n); } -function resumeFrame(tasks, frame) { - tasks.resume(frame) +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}`)) .catch(error => Cli.error(error)); } @@ -33,7 +33,7 @@ function resume() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()).set('project', project), - tasks = new Tasks(config); + timekeeper = new Timekeeper(config); if (!config.get('project')) Cli.error('No project set'); @@ -47,7 +47,7 @@ function resume() { if (!options.ask) { let project = config.get('project'); let filteredFrames = lastFrames.filter(frame => (!project) || frame.project === project); - resumeFrame(tasks, (filteredFrames.length > 0) ? filteredFrames[0] : undefined); + resumeFrame(timekeeper, (filteredFrames.length > 0) ? filteredFrames[0] : undefined); } else { let lastFramesDetails = lastFrames .sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)) @@ -72,7 +72,7 @@ function resume() { choices: lastFramesDetails, pageSize: listSize, }).then((answer) => { - resumeFrame(tasks, answer); + resumeFrame(timekeeper, answer); }); } } diff --git a/src/timekeeping/commands/start.js b/src/timekeeping/commands/start.js index b31c7da..6db0e98 100755 --- a/src/timekeeping/commands/start.js +++ b/src/timekeeping/commands/start.js @@ -3,7 +3,7 @@ import moment from 'moment'; import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; function start() { @@ -19,7 +19,7 @@ function start() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()), - tasks = new Tasks(config), + timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', id = program.args.length === 1 ? parseInt(program.args[0]) : parseInt(program.args[1]), project = program.args.length === 2 ? program.args[0] : null; @@ -40,7 +40,7 @@ if (program.args.length < 2 && !config.get('project')) if (!id) Cli.error('Wrong or missing issue/merge_request id'); -tasks.start(project, type, id, note) +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}`)) .catch(error => Cli.error(error)); } diff --git a/src/timekeeping/commands/status.js b/src/timekeeping/commands/status.js index 2715988..80c4804 100755 --- a/src/timekeeping/commands/status.js +++ b/src/timekeeping/commands/status.js @@ -3,7 +3,7 @@ import colors from 'colors'; import moment from 'moment'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; function status() { @@ -15,9 +15,9 @@ function status() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()), - tasks = new Tasks(config); + timekeeper = new Timekeeper(config); -tasks.status() +timekeeper.status() .then(frames => { if (frames.length === 0) { if (program.opts().s) { diff --git a/src/timekeeping/commands/stop.js b/src/timekeeping/commands/stop.js index bd60b07..6291fee 100755 --- a/src/timekeeping/commands/stop.js +++ b/src/timekeeping/commands/stop.js @@ -3,7 +3,7 @@ import colors from 'colors'; import moment from 'moment'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; function stop() { @@ -14,9 +14,9 @@ function stop() { Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()), - tasks = new Tasks(config); + timekeeper = new Timekeeper(config); -tasks.stop() +timekeeper.stop() .then(frames => { frames.forEach(frame => { if(!frame.resource.new) diff --git a/src/timekeeping/commands/sync.js b/src/timekeeping/commands/sync.js index 88ea65e..0ce8be3 100755 --- a/src/timekeeping/commands/sync.js +++ b/src/timekeeping/commands/sync.js @@ -2,7 +2,7 @@ import moment from 'moment'; import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; -import Tasks from '../storage/tasks.js'; +import Timekeeper from '../timekeeper.js'; import Owner from '../../core/owner.js'; function sync() { @@ -17,24 +17,24 @@ Cli.verbose = program.opts().verbose; let config = new Config(process.cwd()) .set('url', program.opts().url) .set('token', program.opts().token), -tasks = new Tasks(config), +timekeeper = new Timekeeper(config), owner = new Owner(config); -tasks.syncInit() - .then(() => tasks.sync.frames.length === 0 ? process.exit(0) : null) +timekeeper.syncInit() + .then(() => timekeeper.sync.frames.length === 0 ? process.exit(0) : null) .then(() => owner.authorized()) .catch(e => Cli.x(`Invalid access token!`, e)) .then(() => { - Cli.bar(`${Cli.fetch} Fetching or creating issues & merge requests...`, tasks.sync.frames.length); - return tasks.syncResolve(Cli.advance); + Cli.bar(`${Cli.fetch} Fetching or creating issues & merge requests...`, timekeeper.sync.frames.length); + return timekeeper.syncResolve(Cli.advance); }) .then(() => { - Cli.bar(`${Cli.process} Processing issues & merge requests...`, tasks.sync.frames.length); - return tasks.syncDetails(Cli.advance); + Cli.bar(`${Cli.process} Processing issues & merge requests...`, timekeeper.sync.frames.length); + return timekeeper.syncDetails(Cli.advance); }) .then(() => { - Cli.bar(`${Cli.update} Syncing time records...`, tasks.sync.frames.length); - return tasks.syncUpdate(Cli.advance) + Cli.bar(`${Cli.update} Syncing time records...`, timekeeper.sync.frames.length); + return timekeeper.syncUpdate(Cli.advance) }) .catch(error => Cli.x(error)); diff --git a/src/timekeeping/storage/baseFrame.js b/src/timekeeping/storage/baseFrame.js deleted file mode 100755 index 977610a..0000000 --- a/src/timekeeping/storage/baseFrame.js +++ /dev/null @@ -1,117 +0,0 @@ -import Config from '../../core/config.js'; -import moment from 'moment-timezone'; -import Hashids from 'hashids'; -const hashids = new Hashids(); - -class baseFrame { - /** - * constructor. - * @param config - * @param id - * @param type - * @param note - */ - constructor(config, id, type, note) { - this.config = config; - this.project = config.get('project'); - this.resource = {id, type}; - - if(typeof id === 'string' || id instanceof String) - this.resource.new = true; - - this.id = baseFrame.generateId(); - this._start = false; - this._stop = false; - this.timezone = config.get('timezone'); - this.notes = []; - this._note = note; - this._title = null; - } - - static fromJson(config, json) { - let frame = new this(config, json.resource.id, json.resource.type, json.note); - frame.project = json.project; - frame.id = json.id; - frame._start = json.start; - frame._stop = json.stop; - frame.notes = json.notes; - frame.timezone = json.timezone; - frame.modified = json.modified; - frame._title = json.title? json.title: null; - frame.validate(); - - return frame; - } - - validate() { - moment.suppressDeprecationWarnings = true; - - 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; - } - - _getCurrentDate() { - if(this.timezone) - return moment().tz(this.timezone).format(); - - return moment(); - } - - static copy(frame) { - return this.fromJson(Object.assign(new Config, frame.config), { - id: frame.id, - project: frame.project, - resource: frame.resource, - notes: frame.notes, - start: frame._start, - stop: frame._stop, - timezone: frame.timezone, - modified: frame.modified, - title: frame._title, - note: frame._note, - }); - } - - get duration() { - return moment(this.stop).diff(this.start) / 1000; - } - - get date() { - return this.start; - } - - get start() { - return this.timezone ? moment(this._start).tz(this.timezone) : moment(this._start); - } - - get stop() { - return this.timezone ? this._stop ? moment(this._stop).tz(this.timezone) : false : (this._stop ? moment(this._stop) : false ); - } - - set title(title) { - this._title = title; - } - - get title() { - return this._title; - } - - get note() { - return this._note; - } - - /** - * generate a unique id - * @returns {number} - */ - static generateId() { - return hashids.encode(new Date().getTime()); - } -} - -export default baseFrame; diff --git a/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index 3e994cc..f604b9c 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -1,13 +1,72 @@ import fs from 'fs'; import path from 'path'; -import moment from 'moment'; -import BaseFrame from './baseFrame.js'; +import moment from 'moment-timezone'; +import Hashids from 'hashids'; +const hashids = new Hashids(); + +class frame { + /** + * constructor. + * @param config + * @param id + * @param type + * @param note + */ + constructor(config, id, type, note) { + this.config = config; + this.project = config.get('project'); + this.resource = {id, type}; + + if(typeof id === 'string' || id instanceof String) + this.resource.new = true; + + this.id = frame.generateId(); + this._start = false; + this._stop = false; + this.timezone = config.get('timezone'); + this.notes = []; + this._note = note; + this._title = null; + } -class frame extends BaseFrame { static fromFile(config, file) { return frame.fromJson(config, JSON.parse(fs.readFileSync(file))); } + static fromJson(config, json) { + let frame = new this(config, json.resource.id, json.resource.type, json.note); + frame.project = json.project; + frame.id = json.id; + frame._start = json.start; + frame._stop = json.stop; + frame.notes = json.notes; + frame.timezone = json.timezone; + frame.modified = json.modified; + frame._title = json.title? json.title: null; + frame.validate(); + + return frame; + } + + validate() { + moment.suppressDeprecationWarnings = true; + + 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; + } + + _getCurrentDate() { + if(this.timezone) + return moment().tz(this.timezone).format(); + + return moment(); + } + startMe() { this._start = this._getCurrentDate(); this.write(); @@ -48,9 +107,45 @@ class frame extends BaseFrame { }, null, "\t")); } + get duration() { + return moment(this.stop).diff(this.start) / 1000; + } + + get date() { + return this.start; + } + + get start() { + return this.timezone ? moment(this._start).tz(this.timezone) : moment(this._start); + } + + get stop() { + return this.timezone ? this._stop ? moment(this._stop).tz(this.timezone) : false : (this._stop ? moment(this._stop) : false ); + } + + set title(title) { + this._title = title; + } + + get title() { + return this._title; + } + + get note() { + return this._note; + } + get file() { return path.join(this.config.frameDir, this.id + '.json'); } + + /** + * generate a unique id + * @returns {number} + */ + static generateId() { + return hashids.encode(new Date().getTime()); + } } export default frame; diff --git a/src/timekeeping/storage/frameCollection.js b/src/timekeeping/storage/frameCollection.js index f12dddb..13ac7c2 100755 --- a/src/timekeeping/storage/frameCollection.js +++ b/src/timekeeping/storage/frameCollection.js @@ -1,10 +1,10 @@ -import GitlabClient from '../../core/gitlab-client.js'; +import parallel from '../../core/parallel.js'; import Frame from './frame.js'; import Fs from '../../core/filesystem.js'; -class frameCollection extends GitlabClient { +class frameCollection { constructor(config) { - super(config); + this.config = config; this.frames = Fs.readDir(config.frameDir) @@ -44,7 +44,7 @@ class frameCollection extends GitlabClient { } forEach(iterator) { - let promise = this.parallel(this.frames, iterator); + let promise = parallel(this.frames, iterator, this.config); return promise; } diff --git a/src/timekeeping/storage/tasks.js b/src/timekeeping/timekeeper.js similarity index 95% rename from src/timekeeping/storage/tasks.js rename to src/timekeeping/timekeeper.js index a254507..a8b167d 100755 --- a/src/timekeeping/storage/tasks.js +++ b/src/timekeeping/timekeeper.js @@ -1,10 +1,10 @@ import _ from 'underscore'; import moment from 'moment'; -import Fs from '../../core/filesystem.js'; -import Frame from './frame.js'; -import Issue from '../api/issue.js'; -import MergeRequest from '../api/mergeRequest.js'; -import FrameCollection from './frameCollection.js'; +import Fs from '../core/filesystem.js'; +import Frame from './storage/frame.js'; +import Issue from './api/issue.js'; +import MergeRequest from './api/mergeRequest.js'; +import FrameCollection from './storage/frameCollection.js'; const classes = { issue: Issue, @@ -16,7 +16,7 @@ const stop_condition = { flags: "i" }; -class tasks { +class Timekeeper { constructor(config) { this.config = config; this.sync = {}; @@ -190,7 +190,7 @@ class tasks { list(project, state, my) { this.config.set('project', project); - return (new classes['issue'](this.config, {})).list(this.config.get('project'), state, my); + return classes['issue'].list(this.config, this.config.get('project'), state, my); } /** @@ -257,4 +257,4 @@ class tasks { } } -export default tasks; +export default Timekeeper; From 5fcafc534cb849278b9dd807cf93422f6028f4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:54:11 +0200 Subject: [PATCH 03/72] gtt edit: edit multiple entries interactive and using editor (#41) * edit multiple records at once * interactive timeedit flow --- package-lock.json | 105 +++++++++-------- package.json | 1 + src/timekeeping/commands/edit.js | 192 ++++++++++++++++++++++++------- src/timekeeping/storage/frame.js | 10 ++ src/timekeeping/timekeeper.js | 21 ++++ 5 files changed, 240 insertions(+), 89 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11cca80..1fcf6a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.8.1-snapshot", "license": "GPL-2.0", "dependencies": { + "@inquirer/checkbox": "^4.2.3", "@inquirer/select": "^4.2.3", "async": "^3.2.6", "camelcase": "^8.0.0", @@ -520,19 +521,50 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@inquirer/core": { - "version": "10.1.13", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.13.tgz", - "integrity": "sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dependencies": { - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", - "ansi-escapes": "^4.3.2", + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -547,23 +579,23 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz", - "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", "engines": { "node": ">=18" } }, "node_modules/@inquirer/select": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.3.tgz", - "integrity": "sha512-OAGhXU0Cvh0PhLz9xTF/kx6g6x+sP+PcyTiLvCrewI99P3BBeexD+VbuwkNDvqGkk3y2h5ZiWLeRP7BFlhkUDg==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -578,9 +610,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz", - "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "engines": { "node": ">=18" }, @@ -838,20 +870,6 @@ "node": ">= 6.0.0" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -3508,17 +3526,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/underscore": { "version": "1.13.7", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", @@ -3887,9 +3894,9 @@ } }, "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index f2f4a68..bc6e5c9 100755 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "license": "GPL-2.0", "dependencies": { "@inquirer/select": "^4.2.3", + "@inquirer/checkbox": "^4.2.3", "async": "^3.2.6", "camelcase": "^8.0.0", "cli-cursor": "^5.0.0", diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 849b486..901be4f 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -5,18 +5,94 @@ 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/select'; +import select from '@inquirer/checkbox'; +import moment from 'moment'; +import readline from 'readline'; +import Timekeeper from '../timekeeper.js'; +const SHIFT_MINUTE = 1; +const SHIFT_MINUTES = 15; +/** + * interactively edit a frame's start/stop time with keystrokes + * @param config + * @param id + * @returns {Promise} + */ +function interactiveEdit(config, id) { + return new Promise((resolve, reject) => { + let frame = Frame.fromFile(config, Fs.join(config.frameDir, id + '.json')); + let field = 'start'; + + function render() { + Cli.out('\x1Bc'); + Cli.out(`Editing frame ${frame.id}\n\n`); + Cli.out(`${field === 'start' ? '>' : ' '} Start: ${frame.start.format('YYYY-MM-DD HH:mm')}\n`); + Cli.out(`${field === 'stop' ? '>' : ' '} Stop: ${frame.stop ? frame.stop.format('YYYY-MM-DD HH:mm') : '(running)'}\n\n`); + Cli.out('Tab: switch field ↑/↓: ±15 min Enter: save Esc/q: cancel\n'); + } + + function cleanup() { + if (process.stdin.isTTY) process.stdin.setRawMode(false); + process.stdin.removeListener('keypress', onKeypress); + process.stdin.pause(); + } + + function onKeypress(str, key) { + if (key.ctrl && key.name === 'c') { + cleanup(); + process.exit(); + } + + if (key.name === 'tab') { + field = field === 'start' ? 'stop' : 'start'; + } else if (key.name === 'up' || key.name === 'down') { + let shift = key.shift ? SHIFT_MINUTE: SHIFT_MINUTES; + const delta = key.name === 'up' ? shift : -shift; + if (field === 'start') { + frame.start = frame.start.clone().add(delta, 'minutes'); + } else { + frame.stop = (frame.stop || frame.start).clone().add(delta, 'minutes'); + } + } else if (key.name === 'return') { + cleanup(); + try { + frame.write(); + resolve(); + } catch (error) { + reject(error); + } + return; + } else if (key.name === 'escape' || str === 'q') { + cleanup(); + resolve(); + return; + } + + render(); + } + + readline.emitKeypressEvents(process.stdin); + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('keypress', onKeypress); + + render(); + }); +} function edit() { const edit = new Command('edit', 'edit time record by the given id') .arguments('[id]') + .option('-f, --following ', 'edit also the following (by ctime) of the given [id]') + .option('-n, --listsize ', 'list size', 30) + .option('-i, --interactive', 'edit start/stop time interactively with keystrokes') .action((id, opts ,program) => { let config = new Config(process.cwd()); let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeFormat', 'log'); -const listSize = 30; +let timekeeper = new Timekeeper(config); +const listSize = program.opts().listsize; function column(str, n){ if(str.length > n) { @@ -29,49 +105,85 @@ function toHumanReadable(input) { return Time.toHumanReadable(Math.ceil(input), config.get('hoursPerDay'), timeFormat); } -if (!id) { - let lastFrames = Fs.all(config.frameDir).slice(-listSize); // last listSize frames (one page of inquirer) - lastFrames = lastFrames.map((file) => - Frame.fromFile(config, Fs.join(config.frameDir, file.name)) - ); - - 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 - }${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 : "" - }`, - value: frame.id, - }; - }); - - if (lastFramesDetails.length == 0) { - Cli.error("No records found."); - } else { - select({ - message: "Frame?", - default: lastFramesDetails[lastFramesDetails.length - 1].value, - choices: lastFramesDetails, - pageSize: listSize, - }).then((answer) => { - if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { - Cli.error("record not found."); - } else { - Fs.open(Fs.join(config.frameDir, answer + ".json")); +function showMenu() { + timekeeper + .all() + .then(({ frames }) => { + frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); + if (id) { + let idx = frames.findIndex((fr) => fr.id == id); + let following = Number(program.opts().following); + if (!Number.isInteger(following)) { + following = 0; + } + if (idx >= 0) { + frames = frames.slice(idx, idx + following); + } + else + { + frames = []; } + } else { + frames = frames.slice(-listSize); // last listSize frames (one page of inquirer) + } + let lastFramesDetails = frames.map((frame) => { + let issue = `${ + (frame.resource.type + " #" + frame.resource.id).padEnd(20).blue + }${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 : "" + }`, + value: frame.id, + }; }); - } + + if (lastFramesDetails.length == 0) { + Cli.error("No records found."); + } else { + select({ + message: "Frame?", + default: lastFramesDetails[lastFramesDetails.length - 1].value, + choices: lastFramesDetails, + pageSize: listSize, + }).then(async (answers) => { + let didInteractiveEdit = false; + for (const answer of answers) { + if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { + Cli.error("record not found."); + } else if (program.opts().interactive) { + await interactiveEdit(config, answer); + didInteractiveEdit = true; + } else { + Fs.open(Fs.join(config.frameDir, answer + ".json")); + } + } + + if (didInteractiveEdit) { + showMenu(); + } + }); + } + }) + .catch((error) => Cli.error(error)); +} + +if (!id || program.opts().following) { + showMenu(); } else { if (!Fs.exists(Fs.join(config.frameDir, id + ".json"))) Cli.error("No record found."); - Fs.open(Fs.join(config.frameDir, id.replace(".json", "") + ".json")); + if (program.opts().interactive) { + interactiveEdit(config, id.replace(".json", "")).catch((error) => Cli.error(error)); + } else { + Fs.open(Fs.join(config.frameDir, id.replace(".json", "") + ".json")); + } } } diff --git a/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index f604b9c..6e48103 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -119,10 +119,20 @@ class frame { return this.timezone ? moment(this._start).tz(this.timezone) : moment(this._start); } + set start(value) { + this._start = moment.isMoment(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 ); } + set stop(value) { + this._stop = value ? (moment.isMoment(value) ? value.format() : value) : false; + this.validate(); + } + set title(title) { this._title = title; } diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index a8b167d..6c9c24a 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -173,6 +173,27 @@ class Timekeeper { }); } + /** + * + * @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)); + }); + } + /** * * @returns {Promise} From 781bd643dbf6edd04d099474412524678f5b4e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:19:33 +0200 Subject: [PATCH 04/72] dependency update, update to node v24, version to 1.9.0-snapshot (#43) --- .github/workflows/npmci.yml | 2 +- package-lock.json | 2065 ++++++++++++++++++++++------------- package.json | 36 +- 3 files changed, 1327 insertions(+), 776 deletions(-) diff --git a/.github/workflows/npmci.yml b/.github/workflows/npmci.yml index 5691960..34317e0 100644 --- a/.github/workflows/npmci.yml +++ b/.github/workflows/npmci.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - run: npm ci - run: npm test - run: npm run-script build diff --git a/package-lock.json b/package-lock.json index 1fcf6a7..8ba44eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,97 +1,126 @@ { "name": "gitlab-time-tracker", - "version": "1.8.1-snapshot", + "version": "1.9.0-snapshot", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-time-tracker", - "version": "1.8.1-snapshot", + "version": "1.9.0-snapshot", "license": "GPL-2.0", "dependencies": { - "@inquirer/checkbox": "^4.2.3", - "@inquirer/select": "^4.2.3", + "@inquirer/checkbox": "^5.2.1", + "@inquirer/select": "^5.2.1", "async": "^3.2.6", - "camelcase": "^8.0.0", + "camelcase": "^9.0.0", "cli-cursor": "^5.0.0", "cli-table": "^0.3.11", "colors": "^1.4.0", - "commander": "^14.0.0", + "commander": "^15.0.0", "csv-string": "^4.1.1", - "env-paths": "^3.0.0", + "env-paths": "^4.0.0", "find-in-files": "^0.5.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", "markdown-table": "^3.0.4", "moment": "^2.30.1", - "moment-timezone": "^0.6.0", + "moment-timezone": "^0.6.2", "node-spinner": "^0.0.4", - "open": "^10.1.2", + "open": "^11.0.0", "progress": "^2.0.3", "prompt": "^1.3.0", "read-yaml": "^1.1.0", "shelljs": "^0.10.0", - "swissqrbill": "^4.2.0", - "tempfile": "^5.0.0", - "throttled-queue": "^2.1.4", - "underscore": "^1.13.7", + "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" }, "bin": { "gtt": "dist/gtt.cjs" }, "devDependencies": { - "@yao-pkg/pkg": "^6.5.1", - "chai": "^5.2.0", - "esbuild": "^0.25.4", - "mocha": "^11.4.0", - "sinon": "^20.0.0" + "@yao-pkg/pkg": "^6.21.0", + "chai": "^6.2.2", + "esbuild": "^0.28.1", + "mocha": "^11.3.0", + "sinon": "^22.0.0" }, "engines": { - "node": ">=20 <21" + "node": "24" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/generator": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", - "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.3", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", - "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -100,14 +129,49 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", - "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -117,18 +181,20 @@ "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.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -138,13 +204,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -154,13 +221,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -170,13 +238,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -186,13 +255,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -202,13 +272,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -218,13 +289,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -234,13 +306,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -250,13 +323,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -266,13 +340,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -282,13 +357,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -298,13 +374,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -314,13 +391,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -330,13 +408,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -346,13 +425,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -362,13 +442,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -378,13 +459,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -394,13 +476,14 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -410,13 +493,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -426,13 +510,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -442,13 +527,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -457,14 +543,32 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -474,13 +578,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -490,13 +595,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -506,13 +612,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -522,26 +629,27 @@ } }, "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -553,21 +661,21 @@ } }, "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -579,26 +687,27 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -610,11 +719,12 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -630,6 +740,7 @@ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -643,10 +754,11 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -659,6 +771,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -676,6 +789,7 @@ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^7.0.4" }, @@ -684,17 +798,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -702,30 +813,24 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -735,6 +840,7 @@ "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" @@ -747,6 +853,7 @@ "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" } @@ -755,6 +862,7 @@ "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" @@ -768,37 +876,50 @@ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, + "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, + "node_modules/@roberts_lando/vfs": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@roberts_lando/vfs/-/vfs-0.3.3.tgz", + "integrity": "sha512-YjkxVSLw5WMZQoARaryRAjcxA+GbBzWMJdwYZX5oLUt9cC/gew9as4Dn7tcLzPp7BPoR221VpTZ+78TRPawnjg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -807,29 +928,35 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/@yao-pkg/pkg": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.5.1.tgz", - "integrity": "sha512-z6XlySYfnqfm1AfVlBN8A3yeAQniIwL7TKQfDCGsswYSVYLt2snbRefQYsfQQ3pw5lVXrZdLqgTjzaqID9IkWA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.21.0.tgz", + "integrity": "sha512-dZl2C7rdwwEI4tv7WW+Cvnl+2K8OqHKUXfNQRq3mZCTC4degoFx1jA4d5wOn9d8lHrUy58xuEt1eJD0pTddW5w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/generator": "^7.23.0", "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", - "@yao-pkg/pkg-fetch": "3.5.23", - "into-stream": "^6.0.0", - "minimist": "^1.2.6", + "@roberts_lando/vfs": "^0.3.3", + "@yao-pkg/pkg-fetch": "3.6.4", + "esbuild": "^0.28.1", + "into-stream": "^9.1.0", "multistream": "^4.1.0", "picocolors": "^1.1.0", "picomatch": "^4.0.2", + "postject": "^1.0.0-alpha.6", "prebuild-install": "^7.1.1", "resolve": "^1.22.10", + "resolve.exports": "^2.0.3", "stream-meter": "^1.0.4", - "tar": "^7.4.3", + "tar": "^7.5.7", "tinyglobby": "^0.2.11", "unzipper": "^0.12.3" }, @@ -837,44 +964,33 @@ "pkg": "lib-es5/bin.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.23", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.23.tgz", - "integrity": "sha512-rn45sqVQSkcJNSBdTnYze3n+kyub4CN8aiWYlPgA9yp9FZeEF+BlpL68kSIm3HaVuANniF+7RBMH5DkC4zlHZA==", + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.6.4.tgz", + "integrity": "sha512-2JXvS9HbMudLlzEjSaJ7bLNnF/WlTv7iaTAJp2Tk8pBsgwCyL9p4rIN8cztHlyZF7qVzr1EaBjd7DQooFk0azQ==", "dev": true, + "license": "MIT", "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.6", "picocolors": "^1.1.0", "progress": "^2.0.3", "semver": "^7.3.5", - "tar-fs": "^2.1.1", + "tar-fs": "^3.1.1", + "undici": "^7.28.0", "yargs": "^16.2.0" }, "bin": { "pkg-fetch": "lib-es5/bin.js" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -886,6 +1002,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -900,27 +1018,134 @@ "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 - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "engines": { - "node": ">=12" - } + "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==" + "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", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.3.tgz", + "integrity": "sha512-xRgplks8SvcKkdlv2M6Z2LZmRsmqd+x0nXXGXeMEjwdibj1HSDrlnqBRLeYdMvsgCox7Bq0e+DHwfczOfsn6IA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz", + "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -940,44 +1165,34 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, + "license": "MIT", "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -986,6 +1201,7 @@ "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" }, @@ -997,7 +1213,8 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/buffer": { "version": "5.7.1", @@ -1018,6 +1235,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1027,6 +1245,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" }, @@ -1038,30 +1257,25 @@ } }, "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-9.0.0.tgz", + "integrity": "sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw==", + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -1069,6 +1283,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1085,6 +1300,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -1092,20 +1308,12 @@ "node": ">=8" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, + "license": "MIT", "dependencies": { "readdirp": "^4.0.1" }, @@ -1121,6 +1329,7 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } @@ -1129,6 +1338,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" }, @@ -1154,6 +1364,7 @@ "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" } @@ -1162,6 +1373,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", "engines": { "node": ">= 12" } @@ -1171,6 +1383,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -1182,6 +1395,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1190,13 +1404,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -1211,6 +1427,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -1223,6 +1440,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -1239,6 +1457,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1249,34 +1469,40 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "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": "14.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", - "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.12.0" } }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1290,6 +1516,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/csv-string/-/csv-string-4.1.1.tgz", "integrity": "sha512-KGvaJEZEdh2O/EVvczwbPLqJZtSQaWQ4cEJbiOJEG4ALq+dBBqNmBkRXTF4NV79V25+XYtiqbco1IWrmHLm5FQ==", + "license": "MIT", "engines": { "node": ">=12.0" } @@ -1303,10 +1530,11 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1324,6 +1552,7 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -1336,6 +1565,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" }, @@ -1346,28 +1576,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4.0.0" } }, "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" @@ -1380,9 +1603,10 @@ } }, "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -1394,6 +1618,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -1402,19 +1627,21 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -1424,48 +1651,100 @@ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" } }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, + "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1473,31 +1752,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -1505,6 +1785,7 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1514,6 +1795,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -1525,6 +1807,7 @@ "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" @@ -1533,10 +1816,21 @@ "node": ">=4" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "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", @@ -1559,6 +1853,7 @@ "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" }, @@ -1572,13 +1867,15 @@ "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==" + "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", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "dev": true, + "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" } @@ -1587,6 +1884,7 @@ "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" }, @@ -1602,10 +1900,18 @@ "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", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "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", @@ -1617,19 +1923,48 @@ "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", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "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.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -1643,6 +1978,7 @@ "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" }, @@ -1662,6 +1998,7 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/find-in-files/-/find-in-files-0.5.0.tgz", "integrity": "sha512-VraTc6HdtdSHmAp0yJpAy20yPttGKzyBWc7b7FPnnsX9TOgmKx0g9xajizpF/iuu4IvNK4TP0SpyBT9zAlwG+g==", + "license": "MIT", "dependencies": { "find": "^0.1.5", "q": "^1.0.1" @@ -1672,6 +2009,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -1688,6 +2026,7 @@ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, + "license": "BSD-3-Clause", "bin": { "flat": "cli.js" } @@ -1697,6 +2036,7 @@ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, + "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -1708,27 +2048,19 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1743,6 +2075,7 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1752,6 +2085,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -1760,6 +2094,7 @@ "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" }, @@ -1771,13 +2106,16 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -1797,6 +2135,7 @@ "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" }, @@ -1808,13 +2147,15 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1822,18 +2163,21 @@ "node_modules/hash-sum": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz", - "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==" + "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==", + "license": "MIT" }, "node_modules/hashids": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/hashids/-/hashids-2.3.0.tgz", - "integrity": "sha512-ljM73TE/avEhNnazxaj0Dw3BbEUuLC5yYCQ9RSkSUcT4ZSU6ZebdKCIBJ+xT/DnSYW36E9k82GH1Q6MydSIosQ==" + "integrity": "sha512-ljM73TE/avEhNnazxaj0Dw3BbEUuLC5yYCQ9RSkSUcT4ZSU6ZebdKCIBJ+xT/DnSYW36E9k82GH1Q6MydSIosQ==", + "license": "MIT" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -1846,27 +2190,16 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, + "license": "MIT", "bin": { "he": "bin/he" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "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" } @@ -1889,43 +2222,44 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/into-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", - "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-9.1.0.tgz", + "integrity": "sha512-DRsRnQrbzdFjaQ1oe4C6/EIUymIOEix1qROEJTF9dbMq+M4Zrm6VaLp6SD/B9IsiEjPZuBSnWWFN+udajugdWA==", "dev": true, - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -1938,6 +2272,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", "bin": { "is-docker": "cli.js" }, @@ -1952,6 +2287,7 @@ "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" } @@ -1960,6 +2296,7 @@ "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" } @@ -1968,6 +2305,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1976,6 +2315,7 @@ "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" }, @@ -1983,10 +2323,23 @@ "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", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", "dependencies": { "is-docker": "^3.0.0" }, @@ -2004,6 +2357,7 @@ "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" } @@ -2013,14 +2367,28 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "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" }, @@ -2033,6 +2401,7 @@ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -2041,9 +2410,10 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" }, @@ -2058,23 +2428,27 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "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==" + "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", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -2085,11 +2459,29 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "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" }, @@ -2102,6 +2494,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -2110,10 +2503,11 @@ } }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, + "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -2126,6 +2520,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -2137,22 +2532,17 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "dev": true + "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", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -2164,22 +2554,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", - "dev": true - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -2188,12 +2574,14 @@ "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==" + "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" } @@ -2202,6 +2590,7 @@ "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" @@ -2211,9 +2600,10 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "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" }, @@ -2225,6 +2615,7 @@ "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" } @@ -2233,6 +2624,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -2245,6 +2637,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -2253,12 +2646,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2272,24 +2666,27 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, + "license": "MIT", "dependencies": { "minipass": "^7.1.2" }, @@ -2297,44 +2694,31 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/mocha": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.5.0.tgz", - "integrity": "sha512-VKDjhy6LMTKm0WgNEdlY77YVsD49LZnPSXJAaPNL9NRYQADxvORsyG1DIQY6v53BKTnlNbEE2MbVCDbnxr4K3w==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.3.0.tgz", + "integrity": "sha512-J0RLIM89xi8y6l77bgbX+03PeBRDQCOVQpnwOcCN7b8hCmbh6JvGI2ZDJ5WMoHz+IaPU+S4lvTd0j51GmBAdgQ==", "dev": true, + "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^7.0.0", + "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", + "minimatch": "^5.1.6", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", @@ -2358,6 +2742,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2367,6 +2752,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -2380,13 +2766,28 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } }, "node_modules/mocha/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2401,6 +2802,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2413,6 +2815,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2426,10 +2829,11 @@ } }, "node_modules/mocha/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -2447,14 +2851,16 @@ "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.0", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.0.tgz", - "integrity": "sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q==", + "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" }, @@ -2466,7 +2872,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/multistream": { "version": "4.1.0", @@ -2487,44 +2894,34 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "once": "^1.4.0", "readable-stream": "^3.6.0" } }, - "node_modules/multistream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-abi": { - "version": "3.75.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", - "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.3.5" }, @@ -2532,36 +2929,18 @@ "node": ">=10" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true + "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" } @@ -2570,6 +2949,7 @@ "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" }, @@ -2582,6 +2962,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -2590,6 +2971,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" }, @@ -2601,36 +2983,31 @@ } }, "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", "dependencies": { - "default-browser": "^5.2.1", + "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2646,6 +3023,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -2660,13 +3038,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "dev": true, + "license": "BlueOak-1.0.0" }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2675,6 +3055,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", "engines": { "node": ">=8" } @@ -2683,13 +3064,15 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -2701,26 +3084,19 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -2728,11 +3104,51 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "dev": true, + "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -2754,16 +3170,55 @@ "node": ">=10" } }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -2772,6 +3227,7 @@ "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", @@ -2786,13 +3242,15 @@ "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==" + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", + "license": "MIT" }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -2803,6 +3261,7 @@ "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", "engines": { "node": ">=0.6.0", "teleport": ">=0.2.0" @@ -2825,13 +3284,15 @@ "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", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -2841,6 +3302,7 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -2856,6 +3318,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2864,6 +3327,7 @@ "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" }, @@ -2875,6 +3339,7 @@ "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" @@ -2887,14 +3352,16 @@ "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.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "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" @@ -2906,21 +3373,22 @@ "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==" + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, + "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/readdirp": { @@ -2928,6 +3396,7 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.18.0" }, @@ -2941,17 +3410,20 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -2965,10 +3437,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" @@ -2984,6 +3467,7 @@ "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" @@ -2993,14 +3477,16 @@ "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.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -3026,21 +3512,38 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } }, "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "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/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -3053,6 +3556,7 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -3061,6 +3565,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3072,6 +3577,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", "engines": { "node": ">=8" } @@ -3080,6 +3586,7 @@ "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" @@ -3092,6 +3599,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", "engines": { "node": ">=14" }, @@ -3117,7 +3625,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", @@ -3138,6 +3647,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -3145,43 +3655,43 @@ } }, "node_modules/sinon": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-20.0.0.tgz", - "integrity": "sha512-+FXOAbdnj94AQIxH0w1v8gzNxkawVvNqE3jUzRLptR71Oykeu2RrQXXl/VQjKay+Qnh73fDt/oDfMo6xMeDQbQ==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/sinon/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=8" + "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==" + "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": "*" } @@ -3191,24 +3701,72 @@ "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", "dev": true, + "license": "MIT", "dependencies": { "readable-stream": "^2.1.4" } }, - "node_modules/string_decoder": { + "node_modules/stream-meter/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stream-meter/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-meter/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, + "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -3227,6 +3785,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -3241,6 +3800,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3249,13 +3809,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3264,12 +3826,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -3284,6 +3847,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3296,6 +3860,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3304,6 +3869,7 @@ "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" } @@ -3313,6 +3879,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -3325,6 +3892,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3340,6 +3908,7 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3350,12 +3919,14 @@ "node_modules/svg-engine": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/svg-engine/-/svg-engine-0.3.0.tgz", - "integrity": "sha512-s172jAcwfoCcvM/6DwNBvmWN3brztHGFENCR+RU3CBJKeBxPrRlTltVxX1Je5hst782QgP8PM6U37vUR/RhPng==" + "integrity": "sha512-s172jAcwfoCcvM/6DwNBvmWN3brztHGFENCR+RU3CBJKeBxPrRlTltVxX1Je5hst782QgP8PM6U37vUR/RhPng==", + "license": "MIT" }, "node_modules/swissqrbill": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/swissqrbill/-/swissqrbill-4.2.0.tgz", - "integrity": "sha512-wXGTTSpt6zO/p4pURY9pQJkzEfA5xX80jt1jbB1OOTogSNEeBAnoT1KYUj1++LtUb8OoH5GBFCe3EUpHpiB7AA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/swissqrbill/-/swissqrbill-4.3.0.tgz", + "integrity": "sha512-FzSPEVWVQ3R6B0vghqi7VJCLp54AMYxCSiJGr2QzXo8OSU8JBv7XJcF67BAVAriGkuaZqVfhxuD6yRFT6WAXEA==", + "license": "MIT", "dependencies": { "svg-engine": "^0.3.0" }, @@ -3376,16 +3947,16 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -3393,88 +3964,93 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", "dev": true, + "license": "MIT", "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true - }, "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "dev": true, + "license": "MIT", "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "dev": true, + "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "streamx": "^2.12.5" } }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "license": "MIT", "engines": { "node": ">=14.16" } }, "node_modules/tempfile": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-5.0.0.tgz", - "integrity": "sha512-bX655WZI/F7EoTDw9JvQURqAXiPHi8o8+yFxPF2lWYyz1aHnmMRuXWqL6YB6GmeO0o4DIYWHLgGNi/X64T+X4Q==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-6.0.1.tgz", + "integrity": "sha512-DE4nURsf7nUqYHJKTgOVdpt0SBY5r4us4kbFXqg7KZFB7ih27NxIk3qXv29FtqTaE45stnLKTECmSc9ICuRbDQ==", + "license": "MIT", "dependencies": { + "is-safe-filename": "^0.1.1", "temp-dir": "^3.0.0" }, "engines": { - "node": ">=14.18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/throttled-queue": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/throttled-queue/-/throttled-queue-2.1.4.tgz", - "integrity": "sha512-YGdk8sdmr4ge3g+doFj/7RLF5kLM+Mi7DEciu9PHxnMJZMeVuZeTj31g4VE7ekUffx/IdbvrtOCiz62afg0mkg==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/throttled-queue/-/throttled-queue-3.0.0.tgz", + "integrity": "sha512-8X5YUdgDHaqy9DmJzoRWK45o2vzBZ7Rt4bT1Mody4TUuYOXSb7paRwDPVspCZiiW4rxZopgZQAwnhI2LU0QwFQ==", + "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, + "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3487,6 +4063,7 @@ "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" }, @@ -3494,22 +4071,18 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, "node_modules/traverse-chain": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/traverse-chain/-/traverse-chain-0.1.0.tgz", - "integrity": "sha512-up6Yvai4PYKhpNp5PkYtx50m3KbwQrqDwbuZP/ItyL64YEWHAvH6Md83LFLV/GRSk/BoUVwwgUzX6SOQSbsfAg==" + "integrity": "sha512-up6Yvai4PYKhpNp5PkYtx50m3KbwQrqDwbuZP/ItyL64YEWHAvH6Md83LFLV/GRSk/BoUVwwgUzX6SOQSbsfAg==", + "license": "MIT" }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, @@ -3522,33 +4095,47 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" + "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", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/unzipper": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", - "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "dev": true, + "license": "MIT", "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", - "fs-extra": "^11.2.0", + "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } @@ -3557,33 +4144,20 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "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==" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } + "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==", + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -3598,6 +4172,7 @@ "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", @@ -3614,6 +4189,7 @@ "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" } @@ -3622,6 +4198,7 @@ "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" } @@ -3630,20 +4207,8 @@ "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } + "dev": true, + "license": "Apache-2.0" }, "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", @@ -3651,6 +4216,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -3668,6 +4234,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3676,13 +4243,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -3697,6 +4266,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3704,49 +4274,29 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=20" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, "node_modules/xlsx": { "version": "0.20.3", "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -3764,6 +4314,7 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -3773,15 +4324,17 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } }, "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -3800,6 +4353,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } @@ -3809,6 +4363,7 @@ "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, + "license": "MIT", "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", @@ -3824,6 +4379,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -3836,6 +4392,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3844,13 +4401,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -3865,6 +4424,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3877,6 +4437,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -3886,23 +4447,13 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index bc6e5c9..8bcbf9f 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitlab-time-tracker", - "version": "1.8.2-snapshot", + "version": "1.9.0-snapshot", "type": "module", "description": "A command line interface for GitLabs time tracking feature.", "bugs": { @@ -24,7 +24,7 @@ "gtt": "dist/gtt.cjs" }, "engines": { - "node": ">=20 <21" + "node": "24" }, "pkg": { "scripts": "dist/gtt.cjs", @@ -37,39 +37,39 @@ "author": "kriskbx", "license": "GPL-2.0", "dependencies": { - "@inquirer/select": "^4.2.3", - "@inquirer/checkbox": "^4.2.3", + "@inquirer/checkbox": "^5.2.1", + "@inquirer/select": "^5.2.1", "async": "^3.2.6", - "camelcase": "^8.0.0", + "camelcase": "^9.0.0", "cli-cursor": "^5.0.0", "cli-table": "^0.3.11", "colors": "^1.4.0", - "commander": "^14.0.0", + "commander": "^15.0.0", "csv-string": "^4.1.1", - "env-paths": "^3.0.0", + "env-paths": "^4.0.0", "find-in-files": "^0.5.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", "markdown-table": "^3.0.4", "moment": "^2.30.1", - "moment-timezone": "^0.6.0", + "moment-timezone": "^0.6.2", "node-spinner": "^0.0.4", - "open": "^10.1.2", + "open": "^11.0.0", "progress": "^2.0.3", "prompt": "^1.3.0", "read-yaml": "^1.1.0", "shelljs": "^0.10.0", - "swissqrbill": "^4.2.0", - "tempfile": "^5.0.0", - "throttled-queue": "^2.1.4", - "underscore": "^1.13.7", + "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" }, "devDependencies": { - "@yao-pkg/pkg": "^6.5.1", - "chai": "^5.2.0", - "esbuild": "^0.25.4", - "mocha": "^11.4.0", - "sinon": "^20.0.0" + "@yao-pkg/pkg": "^6.21.0", + "chai": "^6.2.2", + "esbuild": "^0.28.1", + "mocha": "^11.3.0", + "sinon": "^22.0.0" } } From 38fa59036af8a388dbd25f5f31a9e38319637722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:44:04 +0200 Subject: [PATCH 05/72] fix dependency update (#45) --- .vscode/launch.json | 18 +++++++++--------- package.json | 6 +++--- src/core/gitlab-client.js | 4 ++-- src/version.js | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index d756b8c..3633785 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "args": ["log"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -24,7 +24,7 @@ "args": ["config"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -36,7 +36,7 @@ "args": ["edit"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -48,7 +48,7 @@ "args": ["resume", "--ask"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -60,7 +60,7 @@ "args": ["stop"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -72,7 +72,7 @@ "args": ["delete"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -84,7 +84,7 @@ "args": ["config"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -96,7 +96,7 @@ "args": ["report", "--last_month", "--query=issues"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" }, { "type": "node", @@ -111,7 +111,7 @@ "--invoiceCurrency", "EUR", "--invoiceCurrencyPerHour", "50", "--invoiceVAT", "0.15", "--invoiceDate", "1.03.2021", "--invoicePositionText", "Position Text"], "program": "${workspaceFolder}/src/gtt.js", "console": "integratedTerminal", - "runtimeExecutable": "${userHome}/.nvm/versions/node/v20.10.0/bin/node" + "runtimeExecutable": "${userHome}/.nvm/versions/node/v24.18.0/bin/node" } ], } \ No newline at end of file diff --git a/package.json b/package.json index 8bcbf9f..9fa3ada 100755 --- a/package.json +++ b/package.json @@ -29,9 +29,9 @@ "pkg": { "scripts": "dist/gtt.cjs", "targets": [ - "node20-linux-x64", - "node20-macos-x64", - "node20-win-x64" + "node24-linux-x64", + "node24-macos-x64", + "node24-win-x64" ] }, "author": "kriskbx", diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 81ab28d..d1578ee 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import throttleFactory from 'throttled-queue'; +import { throttledQueue } from 'throttled-queue'; import parallel from './parallel.js'; /** @@ -11,7 +11,7 @@ class GitlabClient { static init(config) { if(GitlabClient.throttle == undefined){ - GitlabClient.throttle = throttleFactory(config.data.throttleMaxRequestsPerInterval, config.data.throttleInterval); + GitlabClient.throttle = throttledQueue(config.data.throttleMaxRequestsPerInterval, config.data.throttleInterval); } } diff --git a/src/version.js b/src/version.js index 2f67589..797ff63 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -let version = '1.8.2-snapshot'; export default version; +let version = '1.9.0-snapshot'; export default version; From 5bdbc28cfddb7df5e2253c6298be58e61a55900c Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 22:32:45 +0200 Subject: [PATCH 06/72] use pointer file instead of going through all *.json everytime --- package-lock.json | 36 --------------- package.json | 1 - src/core/filesystem.js | 17 ++++--- src/timekeeping/timekeeper.js | 84 ++++++++++++++++++++--------------- 4 files changed, 57 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ba44eb..25d85e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "commander": "^15.0.0", "csv-string": "^4.1.1", "env-paths": "^4.0.0", - "find-in-files": "^0.5.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", "markdown-table": "^3.0.4", @@ -1986,24 +1985,6 @@ "node": ">=8" } }, - "node_modules/find": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/find/-/find-0.1.7.tgz", - "integrity": "sha512-jPrupTOe/pO//3a9Ty2o4NqQCp0L46UG+swUnfFtdmtQVN8pEltKpAqR7Nuf6vWn0GBXx5w+R1MyZzqwjEIqdA==", - "dependencies": { - "traverse-chain": "~0.1.0" - } - }, - "node_modules/find-in-files": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/find-in-files/-/find-in-files-0.5.0.tgz", - "integrity": "sha512-VraTc6HdtdSHmAp0yJpAy20yPttGKzyBWc7b7FPnnsX9TOgmKx0g9xajizpF/iuu4IvNK4TP0SpyBT9zAlwG+g==", - "license": "MIT", - "dependencies": { - "find": "^0.1.5", - "q": "^1.0.1" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3256,17 +3237,6 @@ "once": "^1.3.1" } }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", - "license": "MIT", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4071,12 +4041,6 @@ "node": ">=8.0" } }, - "node_modules/traverse-chain": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/traverse-chain/-/traverse-chain-0.1.0.tgz", - "integrity": "sha512-up6Yvai4PYKhpNp5PkYtx50m3KbwQrqDwbuZP/ItyL64YEWHAvH6Md83LFLV/GRSk/BoUVwwgUzX6SOQSbsfAg==", - "license": "MIT" - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 9fa3ada..4cfe24c 100755 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "commander": "^15.0.0", "csv-string": "^4.1.1", "env-paths": "^4.0.0", - "find-in-files": "^0.5.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", "markdown-table": "^3.0.4", diff --git a/src/core/filesystem.js b/src/core/filesystem.js index de173ee..5b50d42 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -2,18 +2,9 @@ import _ from 'underscore'; import fs from 'fs'; import path from 'path'; import open from 'open'; -import find from 'find-in-files'; import child_process from 'child_process'; class filesystem { - static find(pattern, dir) { - return new Promise((resolve, reject) => { - find.find(pattern, dir) - .then(results => resolve(_.keys(results))) - .catch(error => reject(error)); - }); - } - static exists(file) { return fs.existsSync(file); } @@ -22,6 +13,14 @@ class filesystem { return fs.unlinkSync(file); } + static readText(file) { + return fs.readFileSync(file, 'utf8'); + } + + static writeText(file, data) { + return fs.writeFileSync(file, data); + } + static open(file) { let editor = process.env.VISUAL; diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 6c9c24a..c67416b 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -11,10 +11,7 @@ const classes = { merge_request: MergeRequest }; -const stop_condition = { - term: `"stop" ?: ?false`, - flags: "i" -}; +const CURRENT_FILE = 'current.txt'; class Timekeeper { constructor(config) { @@ -22,6 +19,27 @@ class Timekeeper { this.sync = {}; } + /** + * Path to the pointer file that names the currently + * active (not yet stopped) frame, if any. + */ + _currentFile() { + return Fs.join(this.config.frameDir, CURRENT_FILE); + } + + /** + * id of the currently active frame, or null if none is running. + * Only one frame can be active at a time, so this is a direct + * lookup instead of scanning every frame file. + */ + _currentId() { + if (!Fs.exists(this._currentFile())) return null; + + let id = Fs.readText(this._currentFile()).trim(); + + return id || null; + } + /** * Filter frames that need an update * @returns {Promise} @@ -137,9 +155,11 @@ class Timekeeper { */ status() { return new Promise((resolve, reject) => { - Fs.find(stop_condition, this.config.frameDir) - .then(frames => resolve(frames.map(file => Frame.fromFile(this.config, file)))) - .catch(error => reject(error)); + let id = this._currentId(); + + if (!id) return resolve([]); + + resolve([Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json'))]); }); } @@ -225,14 +245,13 @@ class Timekeeper { this.config.set('project', project); return new Promise((resolve, reject) => { - Fs.find(stop_condition, this.config.frameDir) - .then(frames => { - if (frames.length > 0) - return reject("Already running. Please stop it first with 'gtt stop'."); + if (this._currentId()) + return reject("Already running. Please stop it first with 'gtt stop'."); - resolve(new Frame(this.config, id, type, note).startMe()); - }) - .catch(error => reject(error)); + let frame = new Frame(this.config, id, type, note).startMe(); + Fs.writeText(this._currentFile(), frame.id); + + resolve(frame); }) } @@ -242,16 +261,14 @@ class Timekeeper { */ stop() { return new Promise((resolve, reject) => { - Fs.find(stop_condition, this.config.frameDir) - .then(frames => { - if (frames.length === 0) - return reject('No projects started.'); - - resolve(frames.map(file => { - return Frame.fromFile(this.config, file).stopMe(); - })); - }) - .catch(error => reject(error)); + let id = this._currentId(); + + if (!id) return reject('No projects started.'); + + let frame = Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json')).stopMe(); + Fs.remove(this._currentFile()); + + resolve([frame]); }); } @@ -261,19 +278,16 @@ class Timekeeper { */ cancel() { return new Promise((resolve, reject) => { - Fs.find(stop_condition, this.config.frameDir) - .then(frames => { - if (frames.length === 0) - return reject('No projects started.'); + let id = this._currentId(); - resolve(frames.map(file => { - let frame = Frame.fromFile(this.config, file); - Fs.remove(file); + if (!id) return reject('No projects started.'); - return frame; - })); - }) - .catch(error => reject(error)); + 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]); }); } } From aa59e917d0e5d84e76668635c2b4401f4c68fecf Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 22:51:03 +0200 Subject: [PATCH 07/72] add missing --url / --token and merge request types to list command --- src/timekeeping/api/mergeRequest.js | 15 +++++++++++++++ src/timekeeping/commands/list.js | 27 ++++++++++++++++++++------- src/timekeeping/timekeeper.js | 4 ++-- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/timekeeping/api/mergeRequest.js b/src/timekeeping/api/mergeRequest.js index 17ae63b..6f145ba 100644 --- a/src/timekeeping/api/mergeRequest.js +++ b/src/timekeeping/api/mergeRequest.js @@ -1,4 +1,5 @@ import CoreTask from '../../core/task.js'; +import GitlabClient from '../../core/gitlab-client.js'; import writable from './writable.js'; /** @@ -9,6 +10,20 @@ class mergeRequest extends writable(CoreTask) { constructor(config, data, client) { super(config, data, client, 'merge_requests'); } + + /** + * list merge requests, either of a single project or across all projects + * @returns {Promise} resolving to an array of merge request instances + */ + static list(config, project, state, my, client = new GitlabClient(config)) { + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + const path = project + ? `projects/${encodeURIComponent(project)}/merge_requests?${query}` + : `merge_requests/?${query}`; + + return client.get(path) + .then(response => response.body.map(data => new this(config, data, client))); + } } export default mergeRequest; diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index 5ea11de..3869af8 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -9,27 +9,40 @@ import cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; function list() { - const list = new Command('list', 'list all open issues') + const list = new Command('list', 'list all open issues or merge requests') .arguments('[project]') .option('--verbose', 'show verbose output') .option('-c, --closed', 'show closed issues (instead of opened only)') .option('--my', 'show only issues assigned to me') + .option('-t, --type ', 'specify resource type: issue, merge_request. defaults to issue') + .option('-m', 'shorthand for --type=merge_request') + .option('-i', 'shorthand for --type=issue') + .option('--url ', 'URL to GitLabs API') + .option('--token ', 'API access token') .action((aproject, options, program) => { -let config = new Config(process.cwd()), +let config = new Config(process.cwd()) + .set('url', program.opts().url) + .set('token', program.opts().token), timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', project = program.args[0]; -timekeeper.list(project, program.opts().closed ? 'closed' : 'opened', program.opts().my) - .then(issues => { +if (program.opts().i) { + type = 'issue'; +} else if (program.opts().m) { + type = 'merge_request'; +} + +timekeeper.list(project, type, program.opts().closed ? 'closed' : 'opened', program.opts().my) + .then(tasks => { let table = new Table({ style : {compact : true, 'padding-left' : 1} }); - if (issues.length == 0) { - console.log("No issues found."); + if (tasks.length == 0) { + console.log("No issues or merge requests found."); } - issues.forEach(issue => { + tasks.forEach(issue => { table.push([issue.iid.toString().magenta, issue.title.green + "\n" + issue.data.web_url.gray, issue.state]) }) console.log(table.toString()); diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index c67416b..e3881aa 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -229,9 +229,9 @@ class Timekeeper { }); } - list(project, state, my) { + list(project, type, state, my) { this.config.set('project', project); - return classes['issue'].list(this.config, this.config.get('project'), state, my); + return classes[type].list(this.config, this.config.get('project'), state, my); } /** From bdad48cc95850513ec303db1dd6d3d49af555e92 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 23:19:36 +0200 Subject: [PATCH 08/72] drop xls dependency --- documentation.md | 5 +- package-lock.json | 15 +---- package.json | 3 +- src/reporting/commands/report.js | 8 +-- src/reporting/output/xlsx.js | 107 ------------------------------- 5 files changed, 6 insertions(+), 132 deletions(-) delete mode 100644 src/reporting/output/xlsx.js 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..7cf42ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,7 @@ "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" + "underscore": "^1.13.8" }, "bin": { "gtt": "dist/gtt.cjs" @@ -4261,18 +4260,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..fc101a9 100755 --- a/package.json +++ b/package.json @@ -61,8 +61,7 @@ "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" + "underscore": "^1.13.8" }, "devDependencies": { "@yao-pkg/pkg": "^6.21.0", diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index d8d89cd..e9d2ca0 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/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 @@ -191,9 +190,6 @@ 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 => { 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; From 73427ce34f8ed0d7ee6b21db7347b28061d2df7f Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 23:23:48 +0200 Subject: [PATCH 09/72] fix: return after reject in gitlab-client to stop fall-through execution post/get/graphQL called reject() on non-ok or non-JSON responses but didn't return, so the chain kept running and tried to parse the body anyway, potentially resolving after already rejecting. --- src/core/gitlab-client.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index d1578ee..7e8c647 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -52,7 +52,7 @@ class GitlabClient { body: JSON.stringify(data) }).then(response => { if(!response.ok) { - reject(`Error: response not OK`); + return 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)])); @@ -83,10 +83,10 @@ class GitlabClient { body: JSON.stringify(data) }).then(response => { if(!response.ok) { - reject(`Error: response not OK`); + return reject(`Error: response not OK`); } if(!response.headers.get("content-type").startsWith('application/json')) { - reject(`Error: response not application/json`); + return reject(`Error: response not application/json`); } return (Promise.all([response.json(), Promise.resolve(response.headers)])); }).then(response => { @@ -115,10 +115,10 @@ class GitlabClient { } }).then(response => { if(!response.ok) { - reject(`Error: response not OK`); + return reject(`Error: response not OK`); } if(!response.headers.get("content-type").startsWith('application/json')) { - reject(`Error: response not application/json`); + return reject(`Error: response not application/json`); } return (Promise.all([response.json(), Promise.resolve(response.headers)])); }).then(response => { From 6648ab0e6325dfc81e76edc1002d8c5fc132c45e Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 23:26:55 +0200 Subject: [PATCH 10/72] refactor: use async/await in GitlabClient and Timekeeper Replace manual new Promise(...) wrapping and .then/.catch chains with async/await now that the underlying calls (fetch, throttle, resource methods) are already promise-based. Behavior is unchanged: rejections become thrown errors, still caught identically by callers' .catch(). --- src/core/gitlab-client.js | 113 ++++++++---------- src/timekeeping/timekeeper.js | 218 +++++++++++++++------------------- 2 files changed, 144 insertions(+), 187 deletions(-) diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 7e8c647..168ef72 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -35,64 +35,55 @@ 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) { - return 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)); - })); + }); + + if (!response.ok) throw `Error: response not OK`; + + const isJson = response.headers.get("content-type").startsWith('application/json'); + const body = isJson ? await response.json() : undefined; + + return { body, headers: response.headers }; + }); } /** * query the given path - * @param 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) { - return reject(`Error: response not OK`); - } - if(!response.headers.get("content-type").startsWith('application/json')) { - return 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)); - })); + }); + + if (!response.ok) throw `Error: response not OK`; + if (!response.headers.get("content-type").startsWith('application/json')) throw `Error: response not application/json`; + + return { body: await response.json(), headers: response.headers }; + }); } @@ -104,27 +95,21 @@ class GitlabClient { * @returns {Promise} */ get(path, page = 1, perPage = this._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) { - return reject(`Error: response not OK`); - } - if(!response.headers.get("content-type").startsWith('application/json')) { - return 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)); - })); + }); + + if (!response.ok) throw `Error: response not OK`; + if (!response.headers.get("content-type").startsWith('application/json')) throw `Error: response not application/json`; + + return { body: await response.json(), headers: response.headers }; + }); } /** @@ -135,22 +120,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 +142,14 @@ 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) => { + return parallel(tasks, async (task, done) => { + try { + const response = await this.get(task.path, task.page, task.perPage); response.body.forEach(item => collect.push(item)); done(); - }).catch(error => done(error)); + } catch (error) { + done(error); + } }, this.config, runners); } diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index e3881aa..4404d6b 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -44,13 +44,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()); } /** @@ -60,9 +58,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, done) => { let project = frame.project, type = frame.resource.type, id = frame.resource.id; @@ -79,13 +77,14 @@ class Timekeeper { return done(); } 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); + if (callback) callback(); + done(); + } catch (error) { + done(`Could not resolve ${type} ${id} on "${project}"`); + } }) } @@ -97,7 +96,7 @@ class Timekeeper { 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; } @@ -106,7 +105,7 @@ class Timekeeper { } syncUpdate(callback) { - return this.sync.frames.forEach((frame, done) => { + return this.sync.frames.forEach(async (frame, done) => { let time = frame.duration, project = frame.project, type = frame.resource.type, @@ -115,118 +114,99 @@ class Timekeeper { 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}`)) + try { + await this._addTime(frame, time); + if (callback) callback(); + done(); + } catch (error) { + done(`Could not update ${type} ${id} on ${project}`); + } }); } - _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, 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(); + }); + + 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, done) => { + frames.push(frame); + done(); + }); + + 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 "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 +221,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 "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 '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 '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]; } } From c4363b46ebd3e2dc7468678cfd4c88420e0a5a5d Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 23:32:30 +0200 Subject: [PATCH 11/72] refactor: drop underscore dependency, use native array/object methods Replace all _.each/_.map/_.filter/_.uniq/_.difference/_.intersection/ _.reduce/_.mapObject/_.object/_.extend/_.isObject/_.isArray/_.max/ _.sortBy calls with native Array/Object equivalents, and remove three dead `import _ from 'underscore'` statements that had no usages. --- package-lock.json | 9 +-------- package.json | 3 +-- src/core/cli.js | 9 ++++----- src/core/config.js | 5 ++--- src/core/filesystem.js | 10 +++++++--- src/core/owner.js | 1 - src/core/task.js | 8 +++++--- src/core/time.js | 3 +-- src/reporting/api/report.js | 1 - src/reporting/api/reportable.js | 3 +-- src/reporting/commands/report.js | 11 +++++------ src/reporting/output/base.js | 5 ++--- src/reporting/output/csv.js | 7 +++---- src/reporting/output/invoice.js | 7 ++----- src/reporting/output/markdown.js | 7 +++---- src/reporting/output/table.js | 7 +++---- src/timekeeping/commands/edit.js | 1 - src/timekeeping/commands/log.js | 3 +-- src/timekeeping/timekeeper.js | 5 ++--- 19 files changed, 43 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7cf42ac..e588469 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,8 +32,7 @@ "shelljs": "^0.10.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", - "throttled-queue": "^3.0.0", - "underscore": "^1.13.8" + "throttled-queue": "^3.0.0" }, "bin": { "gtt": "dist/gtt.cjs" @@ -4063,12 +4062,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", diff --git a/package.json b/package.json index fc101a9..2c6c666 100755 --- a/package.json +++ b/package.json @@ -60,8 +60,7 @@ "shelljs": "^0.10.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", - "throttled-queue": "^3.0.0", - "underscore": "^1.13.8" + "throttled-queue": "^3.0.0" }, "devDependencies": { "@yao-pkg/pkg": "^6.21.0", diff --git a/src/core/cli.js b/src/core/cli.js index 16e6409..a0a5199 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import colors from 'colors'; import prompt from 'prompt'; import spinnerFactory from 'node-spinner'; @@ -242,8 +241,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 +257,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; diff --git a/src/core/config.js b/src/core/config.js index ad8cdd6..9b48cdd 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,5 +1,4 @@ import moment from 'moment'; -import _ from 'underscore'; import Time from './time.js'; import EventEmitter from 'events'; @@ -57,7 +56,7 @@ class config extends EventEmitter { constructor() { super(); - this.data = _.extend({}, defaults); + this.data = {...defaults}; } /** @@ -87,7 +86,7 @@ class config extends EventEmitter { if (dates.includes(key)) return moment(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]; diff --git a/src/core/filesystem.js b/src/core/filesystem.js index 5b50d42..163e735 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import fs from 'fs'; import path from 'path'; import open from 'open'; @@ -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) { diff --git a/src/core/owner.js b/src/core/owner.js index ee370f6..736d94d 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import GitlabClient from './gitlab-client.js'; import parallel from './parallel.js'; diff --git a/src/core/task.js b/src/core/task.js index 264b364..bf8b300 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import moment from 'moment'; import GitlabClient from './gitlab-client.js'; @@ -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() { diff --git a/src/core/time.js b/src/core/time.js index 5535f19..6d522eb 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import moment from 'moment'; const defaultTimeFormat = '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'; @@ -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) diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 51a3d6c..5430e3f 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import moment from 'moment'; import GitlabClient from '../../core/gitlab-client.js'; import parallel from '../../core/parallel.js'; diff --git a/src/reporting/api/reportable.js b/src/reporting/api/reportable.js index af7a93c..c6b8eb0 100644 --- a/src/reporting/api/reportable.js +++ b/src/reporting/api/reportable.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import moment from 'moment'; import Time from '../../core/time.js'; import DayReport from './dayReport.js'; @@ -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 e9d2ca0..b4d681c 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import fs from 'fs'; import {Command} from 'commander'; import moment from 'moment'; @@ -23,7 +22,7 @@ function collect(val, arr) { if (!arr) arr = []; arr.push(val); - return _.uniq(arr); + return [...new Set(arr)]; } @@ -161,8 +160,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 @@ -255,8 +254,8 @@ new Promise(resolve => { .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))); + config.set('issueColumns', [...new Set(config.get('issueColumns').concat(columns))]); + config.set('mergeRequestColumns', [...new Set(config.get('mergeRequestColumns').concat(columns))]); done(); }) diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index 5507b5a..b4ae1de 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import fs from 'fs'; import moment from 'moment'; @@ -202,8 +201,8 @@ class Output { 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.users = Object.fromEntries(Object.entries(users).map(([name, user]) => [name, this.config.toHumanReadable(user, 'stats')])); + this.projects = Object.fromEntries(Object.entries(projects).map(([name, project]) => [name, this.config.toHumanReadable(project, 'stats')])); this.stats = { 'total estimate': this.config.toHumanReadable(totalEstimate, 'stats'), 'total spent': this.config.toHumanReadable(totalSpent, 'stats'), diff --git a/src/reporting/output/csv.js b/src/reporting/output/csv.js index 0a63e7c..e1352bd 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'; @@ -11,19 +10,19 @@ class csv 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); }); diff --git a/src/reporting/output/invoice.js b/src/reporting/output/invoice.js index d2ea813..388a087 100644 --- a/src/reporting/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import {markdownTable as Table} from 'markdown-table' import Output from './base.js'; import { SwissQRBill } from "swissqrbill/svg"; @@ -78,15 +77,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* **${name.red}**: ${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, '
'); diff --git a/src/reporting/output/markdown.js b/src/reporting/output/markdown.js index 3f510af..04df776 100755 --- a/src/reporting/output/markdown.js +++ b/src/reporting/output/markdown.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import {markdownTable as Table} from 'markdown-table' import Output from './base.js'; @@ -21,15 +20,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* **${name.red}**: ${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)); } diff --git a/src/reporting/output/table.js b/src/reporting/output/table.js index eb77f7c..3d924a7 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import Table from 'cli-table'; import Output from './base.js'; import Color from 'colors'; @@ -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* ${name.red}: ${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* ${name.red}: ${time}`); stats += `\n`; } - _.each(this.users, (time, name) => stats += `\n* ${name.red}: ${time}`); + Object.entries(this.users).forEach(([name, time]) => stats += `\n* ${name.red}: ${time}`); this.write(stats.substr(1)); } diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 901be4f..8ef792f 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -2,7 +2,6 @@ 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'; diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index f4c7ae9..e6035cb 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import {Command} from 'commander'; import colors from 'colors'; import moment from 'moment-timezone'; @@ -66,7 +65,7 @@ const logCli = (frames, times) => { 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 toSync = (Math.ceil(frame.duration) - parseInt(frame.notes.reduce((n, m) => (n + m.time), 0))) != 0; let durationText = toSync ? toHumanReadable(frame.duration).padEnd(14).yellow : toHumanReadable(frame.duration).padEnd(14); let issue = frame.resource.new ? column(`(new ${frame.resource.type + ' "' + frame.resource.id}")`, 70).bgBlue: diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 4404d6b..a061b8e 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import moment from 'moment'; import Fs from '../core/filesystem.js'; import Frame from './storage/frame.js'; @@ -48,7 +47,7 @@ class Timekeeper { 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))); + this.sync.frames.filter(frame => !(Math.ceil(frame.duration) === frame.notes.reduce((n, m) => (n + m.time), 0))); } /** @@ -112,7 +111,7 @@ class Timekeeper { id = frame.resource.id; if (frame.notes.length > 0) - time = Math.ceil(frame.duration) - parseInt(_.reduce(frame.notes, (n, m) => (n + m.time), 0)); + time = Math.ceil(frame.duration) - parseInt(frame.notes.reduce((n, m) => (n + m.time), 0)); try { await this._addTime(frame, time); From 1c31dee433ba411c9c6a632e5dbf9ec1a3f7ff3d Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 23:35:14 +0200 Subject: [PATCH 12/72] refactor: drop shelljs dependency, use native fs.mkdirSync shelljs was pulled in for a single call, shell.mkdir('-p', dir), used three times to create the global/cache/frame directories. Node's fs.mkdirSync(dir, { recursive: true }) does the same without a shell subprocess or the extra dependency weight. --- package-lock.json | 358 +--------------------------------------- package.json | 1 - src/core/file-config.js | 7 +- 3 files changed, 9 insertions(+), 357 deletions(-) diff --git a/package-lock.json b/package-lock.json index e588469..66a0e30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "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" @@ -833,41 +832,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", @@ -1194,18 +1158,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", @@ -1499,6 +1451,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", @@ -1823,50 +1776,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", @@ -1904,22 +1813,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", @@ -1944,15 +1837,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", @@ -1971,18 +1855,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", @@ -2069,18 +1941,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", @@ -2110,18 +1970,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", @@ -2174,15 +2022,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", @@ -2271,15 +2110,6 @@ "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", @@ -2290,18 +2120,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", @@ -2332,15 +2150,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", @@ -2363,18 +2172,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", @@ -2414,6 +2211,7 @@ "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": { @@ -2550,55 +2348,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", @@ -2924,18 +2673,6 @@ "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", @@ -3034,6 +2771,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" @@ -3235,26 +2973,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", @@ -3431,16 +3149,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", @@ -3462,29 +3170,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", @@ -3533,6 +3218,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" @@ -3545,24 +3231,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", @@ -3833,15 +3507,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", @@ -4027,18 +3692,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", @@ -4113,6 +3766,7 @@ "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" diff --git a/package.json b/package.json index 2c6c666..4de23d0 100755 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "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" diff --git a/src/core/file-config.js b/src/core/file-config.js index 9138e96..2db94c3 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -1,5 +1,4 @@ import fs from 'fs'; -import shell from 'shelljs'; import path from 'path'; import os from 'os'; import config from './config.js'; @@ -22,7 +21,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, @@ -93,8 +92,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, ''); } From 6f7608031d303ed29a85d92043fefce56b0eb53b Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 3 Jul 2026 23:46:04 +0200 Subject: [PATCH 13/72] refactor: replace prompt with @inquirer/confirm for Cli.ask The only remaining prompt usage was a single yes/no confirmation (overwrite existing report file). @inquirer/confirm matches the @inquirer/core generation already used by checkbox/select, so no duplicate @inquirer/core install, and drops prompt plus its 14 transitive dependencies. --- package-lock.json | 153 +++++++--------------------------------------- package.json | 2 +- src/core/cli.js | 24 ++------ 3 files changed, 28 insertions(+), 151 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66a0e30..be13869 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "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", @@ -27,7 +28,6 @@ "node-spinner": "^0.0.4", "open": "^11.0.0", "progress": "^2.0.3", - "prompt": "^1.3.0", "read-yaml": "^1.1.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", @@ -173,15 +173,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", @@ -656,6 +647,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", @@ -1471,14 +1483,6 @@ "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1798,14 +1802,6 @@ "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", @@ -2214,12 +2210,6 @@ "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", @@ -2308,12 +2298,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", @@ -2940,28 +2924,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", @@ -3009,18 +2971,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", @@ -3056,12 +3006,6 @@ "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", @@ -3149,15 +3093,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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", @@ -3329,15 +3264,6 @@ "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", @@ -3778,41 +3704,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", diff --git a/package.json b/package.json index 4de23d0..5798825 100755 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "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", @@ -55,7 +56,6 @@ "node-spinner": "^0.0.4", "open": "^11.0.0", "progress": "^2.0.3", - "prompt": "^1.3.0", "read-yaml": "^1.1.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", diff --git a/src/core/cli.js b/src/core/cli.js index a0a5199..a64a943 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -1,5 +1,5 @@ import colors from 'colors'; -import prompt from 'prompt'; +import confirm from '@inquirer/confirm'; import spinnerFactory from 'node-spinner'; const spinner = spinnerFactory(); import cursor from 'cli-cursor'; @@ -55,24 +55,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'); } /** From 20f3e6ae3c78ca774ffe215517b2b0df3bbb5055 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:14:57 +0200 Subject: [PATCH 14/72] refactor: rewrite report command as async/await Replaces the promise-chain waterfall of new-Promise wrappers with a flat async action. Fixes error-handling defects in the old chain: - .catch(error => done(error)).then(() => done()) called the parallel worker callback twice on failure (merge requests, timelogs) - report.getIssues() had no rejection handler, hanging the run on error - .catch(e => reject(e)).then(...) resolved after rejecting and reset the project config even on failure Behavior is unchanged: same stage order, same messages, Cli.x/Cli.error still terminate the process on fatal errors. Co-Authored-By: Claude Fable 5 --- src/reporting/commands/report.js | 379 ++++++++++++++++--------------- 1 file changed, 197 insertions(+), 182 deletions(-) diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index b4d681c..200e935 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -76,7 +76,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()); @@ -191,197 +191,212 @@ if (!config.get('to').isValid()) { } // 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, done) => { + config.set('project', project); + + try { + switch (config.get('type')) { + case 'project': { + let report = new Report(config, undefined, client); + try { + await report.getProject(); + } catch (error) { + Cli.x(`Project not found or no access rights "${projectLabels}".`, error); + } + reports.push(report); + break; + } + + case 'group': { + await owner.getGroup(); + if (config.get('subgroups')) await owner.getSubGroups(); + await owner.getProjectsByGroup(); + owner.projects.forEach(project => reports.push(new Report(config, project, client))); + break; } - }, 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`)) + } + done(); + } catch (error) { + done(error); + } + }, config, 1); + + config.set('project', projects); + Cli.out(`\r${Cli.look} Selected projects: ${reports.reports.map(r => r.project.name.bold.blue).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', [...new Set(config.get('issueColumns').concat(columns))]); - config.set('mergeRequestColumns', [...new Set(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); + if (config.get('userColumns')) { + await reports.forEach(async (report, done) => { + try { + 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))]); + 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) { + done(error); + } + }); + } + + 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(async (report, done) => { + try { + await report.getIssues(); + done(); + } catch (error) { + done(error); + } + }); + } 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(async (report, done) => { + try { + await report.getMergeRequests(); + done(); + } catch (error) { + done(error); + } + }); + } catch (error) { + Cli.x(`could not fetch merge requests.`, error); + } + + Cli.mark(); +} + +// get timelogs +Cli.list(`${Cli.fetch} Loading timelogs`); + +try { + await reports.forEach(async (report, done) => { + try { + await report.getTimelogs(); + done(); + } catch (error) { + done(error); } - })) - .catch(error => Cli.x(`could not print report.`, error)) - .then(() => Cli.mark()) + }); +} catch (error) { + Cli.x(`could not load timelogs.`, error); +} + +Cli.mark(); + +// merge reports +Cli.list(`${Cli.merge} Merging reports`); + +try { + await reports.forEach((report, done) => { + master.merge(report); + done(); + }); +} catch (error) { + Cli.x(`could not merge reports.`, error); +} + +Cli.mark(); - // time for a beer - .then(() => Cli.done()); +// 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; } From 07f990a1dc3a6f9a13f069bd76aeea16a2c50413 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:15:18 +0200 Subject: [PATCH 15/72] fix: send API token only via headers, never in URL or body The private token was appended to every GET query string and injected into every POST body in addition to the PRIVATE-TOKEN header. Tokens in URLs leak into proxy/server logs and shell history; the header alone is sufficient for both REST endpoints (GraphQL already uses a Bearer header). Co-Authored-By: Claude Fable 5 --- src/core/gitlab-client.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 168ef72..8b170e3 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -38,8 +38,6 @@ class GitlabClient { * @returns {Promise} */ post(path, data) { - data.private_token = this.token; - return GitlabClient.throttle(async () => { const response = await fetch(`${this.url}${path}`, { method: 'POST', @@ -95,8 +93,7 @@ class GitlabClient { * @returns {Promise} */ get(path, page = 1, perPage = this._perPage) { - path += (path.includes('?') ? '&' : '?') + `private_token=${this.token}`; - path += `&page=${page}&per_page=${perPage}`; + path += (path.includes('?') ? '&' : '?') + `page=${page}&per_page=${perPage}`; return GitlabClient.throttle(async () => { const response = await fetch(`${this.url}${path}`, { From b4b2911950903f0889ea9c5d46b850f79526bc28 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:16:52 +0200 Subject: [PATCH 16/72] test: cover Output.calculate/prepare and Frame persistence Adds the first tests for the report aggregation (per-user/per-project totals, estimate/spent sums, free and half-price label accounting, sorting, per-day consolidation, column preparation) and for Frame (new-resource detection, start/stop file round-trip, duration, date validation). These protect the areas the ongoing refactoring touches most. Co-Authored-By: Claude Fable 5 --- spec/models/frame.spec.js | 93 ++++++++++++++++++++++++++ spec/output/base.spec.js | 133 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 spec/models/frame.spec.js create mode 100644 spec/output/base.spec.js diff --git a/spec/models/frame.spec.js b/spec/models/frame.spec.js new file mode 100644 index 0000000..a33119f --- /dev/null +++ b/spec/models/frame.spec.js @@ -0,0 +1,93 @@ +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' + }; + + let caught = null; + try { + Frame.fromJson(config, { ...json, start: 'not a date' }); + } catch (error) { + caught = error; + } + expect(caught).to.match(/Start date/); + + caught = null; + try { + Frame.fromJson(config, { ...json, stop: 'not a date' }); + } catch (error) { + caught = error; + } + expect(caught).to.match(/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/output/base.spec.js b/spec/output/base.spec.js new file mode 100644 index 0000000..670425f --- /dev/null +++ b/spec/output/base.spec.js @@ -0,0 +1,133 @@ +import moment from 'moment'; +import { expect } from 'chai'; +import Config from '../../src/core/config.js'; +import Output from '../../src/reporting/output/base.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: moment(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('Output.calculate', () => { + let config; + + beforeEach(() => { + config = new Config(); + }); + + function calculate(issues = [], mergeRequests = []) { + return new Output(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 = new Output(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 moments, 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: moment('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']); + }); +}); From a52a5e80a5a5e22285e18f7ffc216c9c5f53020e Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:31:29 +0200 Subject: [PATCH 17/72] refactor: throw Error objects instead of strings String throws lose stack traces and break instanceof checks. GitlabClient errors now include the HTTP method, path, status and (truncated) response body, so failed syncs report the actual GitLab error instead of a bare 'response not OK'. Cli.error accepts Error instances since commands pass thrown values straight through. Also guards against responses without a content-type header. Co-Authored-By: Claude Fable 5 --- spec/models/frame.spec.js | 17 +-------- src/core/cli.js | 5 +++ src/core/gitlab-client.js | 42 ++++++++++++++++++--- src/timekeeping/storage/frame.js | 4 +- src/timekeeping/storage/frameCollection.js | 2 +- src/timekeeping/timekeeper.js | 12 +++--- tasks.md | 43 ++++++++++++++++++++++ 7 files changed, 95 insertions(+), 30 deletions(-) create mode 100644 tasks.md diff --git a/spec/models/frame.spec.js b/spec/models/frame.spec.js index a33119f..fa09d84 100644 --- a/spec/models/frame.spec.js +++ b/spec/models/frame.spec.js @@ -63,21 +63,8 @@ describe('frame class', () => { timezone: 'UTC' }; - let caught = null; - try { - Frame.fromJson(config, { ...json, start: 'not a date' }); - } catch (error) { - caught = error; - } - expect(caught).to.match(/Start date/); - - caught = null; - try { - Frame.fromJson(config, { ...json, stop: 'not a date' }); - } catch (error) { - caught = error; - } - expect(caught).to.match(/Stop date/); + 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', () => { diff --git a/src/core/cli.js b/src/core/cli.js index a64a943..a6ea30e 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -202,6 +202,11 @@ class cli { static error(message, error) { 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); diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 8b170e3..990d4b5 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -48,15 +48,45 @@ class GitlabClient { body: JSON.stringify(data) }); - if (!response.ok) throw `Error: response not OK`; + await GitlabClient.assertOk(response, 'POST', path); - const isJson = response.headers.get("content-type").startsWith('application/json'); + 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}` : ''}`); + } + + /** + * 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 @@ -77,8 +107,8 @@ class GitlabClient { body: JSON.stringify(data) }); - if (!response.ok) throw `Error: response not OK`; - if (!response.headers.get("content-type").startsWith('application/json')) throw `Error: response not application/json`; + await GitlabClient.assertOk(response, 'POST', path); + GitlabClient.assertJson(response, 'POST', path); return { body: await response.json(), headers: response.headers }; }); @@ -102,8 +132,8 @@ class GitlabClient { } }); - if (!response.ok) throw `Error: response not OK`; - if (!response.headers.get("content-type").startsWith('application/json')) throw `Error: response not application/json`; + await GitlabClient.assertOk(response, 'GET', path); + GitlabClient.assertJson(response, 'GET', path); return { body: await response.json(), headers: response.headers }; }); diff --git a/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index 6e48103..93cf821 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -52,10 +52,10 @@ class frame { moment.suppressDeprecationWarnings = true; if(!moment(this._start).isValid()) - throw `Error: Start date is not in a valid ISO date format!`; + throw new 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!`; + throw new Error(`Stop date is not in a valid ISO date format!`); moment.suppressDeprecationWarnings = false; } diff --git a/src/timekeeping/storage/frameCollection.js b/src/timekeeping/storage/frameCollection.js index 13ac7c2..a333fe1 100755 --- a/src/timekeeping/storage/frameCollection.js +++ b/src/timekeeping/storage/frameCollection.js @@ -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); diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index a061b8e..574c33d 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -82,7 +82,7 @@ class Timekeeper { if (callback) callback(); done(); } catch (error) { - done(`Could not resolve ${type} ${id} on "${project}"`); + done(new Error(`Could not resolve ${type} ${id} on "${project}": ${error.message ?? error}`)); } }) } @@ -118,7 +118,7 @@ class Timekeeper { if (callback) callback(); done(); } catch (error) { - done(`Could not update ${type} ${id} on ${project}`); + done(new Error(`Could not update ${type} ${id} on ${project}: ${error.message ?? error}`)); } }); } @@ -202,7 +202,7 @@ class Timekeeper { */ async resume(frame) { if (!frame) { - throw "No task found to resume."; + throw new Error("No task found to resume."); } return this.start(frame.project, frame.resource.type, frame.resource.id, frame.note); @@ -224,7 +224,7 @@ class Timekeeper { this.config.set('project', project); if (this._currentId()) - throw "Already running. Please stop it first with 'gtt stop'."; + 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); @@ -239,7 +239,7 @@ class Timekeeper { async stop() { let id = this._currentId(); - if (!id) throw '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()); @@ -254,7 +254,7 @@ class Timekeeper { async cancel() { let id = this._currentId(); - if (!id) throw '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); diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..8f249c9 --- /dev/null +++ b/tasks.md @@ -0,0 +1,43 @@ +# Refactoring / Modernization Tasks + +From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. + +## Done + +- [x] Rewrite `report` command as flat async/await; fix double-`done` callbacks, + unhandled `getIssues()` rejection, resolve-after-reject flow (`31ee12e`) +- [x] Send API token only via headers, never in URL query string or POST body (`374e2f8`) +- [x] Add tests for `Output.calculate()`/`prepare()` and `Frame` persistence (`a2524cf`) + +## Design + +- [x] Throw `Error` objects instead of strings (`timekeeper.js`, `frame.js`, + `gitlab-client.js`, `frameCollection.js`); include HTTP status, path and + GitLab error body in `GitlabClient` errors. Then switch the frame specs + to idiomatic `expect(...).to.throw(/Start date/)`. +- [ ] Make `frame.write()` atomic: replace `unlinkSync` + `appendFileSync` with + `writeFileSync` (or temp file + rename). +- [ ] Extract billing/aggregation logic (`calculate()`) out of the `Output` + base class into a report model; presentation layer should only render. + Behavior is pinned by `spec/output/base.spec.js`. +- [ ] Remove shared `Config` mutation: stop `config.set('project', ...)` inside + the report parallel loop (only safe because runners = 1); pass the + project into `Report` explicitly. Drop unused `EventEmitter` inheritance; + reconsider magic special cases in `config.get()`. +- [ ] Convert callback-style `forEach(item, done)` in `FrameCollection` / + `ReportCollection` to async iteration; removes try/done boilerplate. +- [ ] Minor: capitalize class names (`config`, `frame`, `cli`); move + `ReportCollection`'s module-level `projlist` into the instance + (currently leaks state across instances). + +## Tech stack + +- [ ] Replace `moment` + `moment-timezone` with Luxon or dayjs + (biggest binary-size win for the pkg build). +- [ ] Drop `async` dependency: only `eachLimit` is used — replace with a small + native concurrency limiter or `p-limit`. +- [ ] Replace `colors` (abandoned, 2022 sabotage incident) with + `picocolors` or `chalk`. +- [ ] Replace `read-yaml` (ancient wrapper) with `js-yaml` directly; + replace `node-spinner` 0.0.4 with a maintained alternative. +- [ ] Align esbuild target (`node20`) with `engines`/pkg (`node24`). From 4be394e16d7566cffe96d4e84c52dab99f2b7250 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:32:11 +0200 Subject: [PATCH 18/72] fix: write frame files atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frame.write() used unlinkSync + appendFileSync; a crash between the two lost the frame, and a crash mid-append left a truncated JSON file. Write to .tmp and rename over the target instead — rename is atomic on the same filesystem, and FrameCollection only picks up *.json, so a leftover .tmp can never be parsed as a frame. Also removes the unused assertFile() helper. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + src/timekeeping/storage/frame.js | 17 +++++++---------- tasks.md | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) 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/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index 93cf821..3c66ee8 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -82,18 +82,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, @@ -105,6 +101,7 @@ class frame { title: this._title, note: this._note }, null, "\t")); + fs.renameSync(tmpFile, this.file); } get duration() { diff --git a/tasks.md b/tasks.md index 8f249c9..e79349d 100644 --- a/tasks.md +++ b/tasks.md @@ -15,7 +15,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. `gitlab-client.js`, `frameCollection.js`); include HTTP status, path and GitLab error body in `GitlabClient` errors. Then switch the frame specs to idiomatic `expect(...).to.throw(/Start date/)`. -- [ ] Make `frame.write()` atomic: replace `unlinkSync` + `appendFileSync` with +- [x] Make `frame.write()` atomic: replace `unlinkSync` + `appendFileSync` with `writeFileSync` (or temp file + rename). - [ ] Extract billing/aggregation logic (`calculate()`) out of the `Output` base class into a report model; presentation layer should only render. From c4b3b625953853b44957afe5750cf95c0a9f2a68 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:35:49 +0200 Subject: [PATCH 19/72] refactor: extract report aggregation out of the Output base class Moves calculate() into src/reporting/stats.js as a standalone calculateStats(config, report) function; the Output constructor assigns its result, so subclasses keep reading this.times/this.stats/etc. unchanged. Billing logic (free/half-price labels, now via Array.some/includes instead of bitwise |= loops) no longer lives in the presentation layer, and the specs test the function directly. Co-Authored-By: Claude Fable 5 --- spec/output/base.spec.js | 7 +- src/reporting/output/base.js | 121 +---------------------------------- src/reporting/stats.js | 108 +++++++++++++++++++++++++++++++ tasks.md | 2 +- 4 files changed, 116 insertions(+), 122 deletions(-) create mode 100644 src/reporting/stats.js diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js index 670425f..d3530a1 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -2,6 +2,7 @@ import moment from 'moment'; 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: moment(date) }; @@ -17,7 +18,7 @@ function makeIssue({ iid = 1, labels = [], times = [], days = {}, estimate = 0, }; } -describe('Output.calculate', () => { +describe('calculateStats', () => { let config; beforeEach(() => { @@ -25,7 +26,7 @@ describe('Output.calculate', () => { }); function calculate(issues = [], mergeRequests = []) { - return new Output(config, { issues, mergeRequests }); + return calculateStats(config, { issues, mergeRequests }); } it('aggregates spent time per user and per project', () => { @@ -94,7 +95,7 @@ describe('Output.calculate', () => { ], mergeRequests: [] }; - const output = new Output(config, report); + 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([ diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index b4ae1de..f6141fc 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -1,5 +1,6 @@ import fs from 'fs'; import moment from 'moment'; +import calculateStats from '../stats.js'; const defaultFormats = { headline: h => `${h}\n`, @@ -8,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 { @@ -22,7 +23,7 @@ class Output { this.report = report; this.out = ''; this.formats = defaultFormats; - this.calculate(); + Object.assign(this, calculateStats(config, report)); } set format(value) { @@ -101,122 +102,6 @@ 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 = Object.fromEntries(Object.entries(users).map(([name, user]) => [name, this.config.toHumanReadable(user, 'stats')])); - this.projects = Object.fromEntries(Object.entries(projects).map(([name, project]) => [name, 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 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/tasks.md b/tasks.md index e79349d..23cdf4a 100644 --- a/tasks.md +++ b/tasks.md @@ -17,7 +17,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. to idiomatic `expect(...).to.throw(/Start date/)`. - [x] Make `frame.write()` atomic: replace `unlinkSync` + `appendFileSync` with `writeFileSync` (or temp file + rename). -- [ ] Extract billing/aggregation logic (`calculate()`) out of the `Output` +- [x] Extract billing/aggregation logic (`calculate()`) out of the `Output` base class into a report model; presentation layer should only render. Behavior is pinned by `spec/output/base.spec.js`. - [ ] Remove shared `Config` mutation: stop `config.set('project', ...)` inside From 11768584472d76859fd7477d7192b930b09af278 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:36:28 +0200 Subject: [PATCH 20/72] refactor: stop mutating shared config inside the report project loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report.getProject() and Owner.getGroup() now take the project/group path as a parameter instead of reading it from config, so the report command no longer rewrites config 'project' on every loop iteration — that was only safe because the loop ran with a single runner. Also drops the unused EventEmitter inheritance from the config class. Co-Authored-By: Claude Fable 5 --- src/core/config.js | 5 +---- src/core/owner.js | 5 +++-- src/reporting/api/report.js | 5 +++-- src/reporting/commands/report.js | 6 ++---- tasks.md | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/core/config.js b/src/core/config.js index 9b48cdd..d8376aa 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,6 +1,5 @@ import moment from 'moment'; import Time from './time.js'; -import EventEmitter from 'events'; const dates = ['from', 'to']; const objectsWithDefaults = ['timeFormat', 'columns']; @@ -49,13 +48,11 @@ const defaults = { /** * basic config */ -class config extends EventEmitter { +class config { /** * construct */ constructor() { - super(); - this.data = {...defaults}; } diff --git a/src/core/owner.js b/src/core/owner.js index 736d94d..b511645 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -32,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(); diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 5430e3f..2ef3c97 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -66,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; diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index 200e935..8415107 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -211,14 +211,12 @@ try { try { await parallel(projects, async (project, done) => { - config.set('project', project); - try { switch (config.get('type')) { case 'project': { let report = new Report(config, undefined, client); try { - await report.getProject(); + await report.getProject(project); } catch (error) { Cli.x(`Project not found or no access rights "${projectLabels}".`, error); } @@ -227,7 +225,7 @@ try { } case 'group': { - await owner.getGroup(); + 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))); diff --git a/tasks.md b/tasks.md index 23cdf4a..c3f1bdb 100644 --- a/tasks.md +++ b/tasks.md @@ -20,7 +20,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. - [x] Extract billing/aggregation logic (`calculate()`) out of the `Output` base class into a report model; presentation layer should only render. Behavior is pinned by `spec/output/base.spec.js`. -- [ ] Remove shared `Config` mutation: stop `config.set('project', ...)` inside +- [x] Remove shared `Config` mutation: stop `config.set('project', ...)` inside the report parallel loop (only safe because runners = 1); pass the project into `Report` explicitly. Drop unused `EventEmitter` inheritance; reconsider magic special cases in `config.get()`. From 500013edec2a5948dd59d96fe50ca391bf598c17 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:38:55 +0200 Subject: [PATCH 21/72] refactor: replace node-style done callbacks with async workers parallel() now takes an async (task) => void worker (plain sync functions also work); FrameCollection/ReportCollection.forEach pass the same contract through. Removes the try/done boilerplate in every caller and fixes the remaining double-callback in Report.process(), where getStats() failures invoked done twice via .catch(done).then(done). Co-Authored-By: Claude Fable 5 --- src/core/gitlab-client.js | 11 +--- src/core/owner.js | 9 +-- src/core/parallel.js | 8 +-- src/reporting/api/report.js | 20 ++---- src/reporting/api/reportCollection.js | 2 +- src/reporting/commands/report.js | 87 ++++++++------------------- src/timekeeping/timekeeper.js | 30 ++++----- tasks.md | 2 +- 8 files changed, 56 insertions(+), 113 deletions(-) diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 990d4b5..1ab4ea5 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -169,14 +169,9 @@ class GitlabClient { * @returns {Promise} */ getParallel(tasks, collect = [], runners = this._parallel) { - return parallel(tasks, async (task, done) => { - try { - const response = await this.get(task.path, task.page, task.perPage); - 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 b511645..b3a2ce0 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -121,13 +121,8 @@ 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); } } diff --git a/src/core/parallel.js b/src/core/parallel.js index 2afb4d0..db75308 100644 --- a/src/core/parallel.js +++ b/src/core/parallel.js @@ -1,17 +1,17 @@ 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 on the first worker error. * @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 => { + async.eachLimit(Array.from(tasks), runners, async task => worker(task), error => { if (error) return reject(error); resolve(); }); diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 2ef3c97..ce438ee 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -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)); diff --git a/src/reporting/api/reportCollection.js b/src/reporting/api/reportCollection.js index d23bab3..5e3f914 100755 --- a/src/reporting/api/reportCollection.js +++ b/src/reporting/api/reportCollection.js @@ -9,7 +9,7 @@ class reportCollection { } forEach(iterator) { - return parallel(this.reports, (report, done) => iterator(report, done), this.config); + return parallel(this.reports, iterator, this.config); } push(report) { diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index 8415107..dbe443c 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -210,31 +210,26 @@ try { } try { - await parallel(projects, async (project, done) => { - try { - 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); - } - reports.push(report); - break; + 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); } + 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; - } + 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; } - done(); - } catch (error) { - done(error); } }, config, 1); @@ -243,18 +238,12 @@ try { // get members and user columns if (config.get('userColumns')) { - await reports.forEach(async (report, done) => { - try { - 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))]); + await reports.forEach(async report => { + await report.project.members(); + let columns = report.project.users.map(user => `time_${user}`); - done(); - } catch (error) { - done(error); - } + config.set('issueColumns', [...new Set(config.get('issueColumns').concat(columns))]); + config.set('mergeRequestColumns', [...new Set(config.get('mergeRequestColumns').concat(columns))]); }); } @@ -268,14 +257,7 @@ if (config.get('query').includes('issues')) { Cli.list(`${Cli.fetch} Fetching issues`); try { - await reports.forEach(async (report, done) => { - try { - await report.getIssues(); - done(); - } catch (error) { - done(error); - } - }); + await reports.forEach(report => report.getIssues()); } catch (error) { Cli.x(`could not fetch issues.`, error); } @@ -288,14 +270,7 @@ if (config.get('query').includes('merge_requests')) { Cli.list(`${Cli.fetch} Fetching merge requests`); try { - await reports.forEach(async (report, done) => { - try { - await report.getMergeRequests(); - done(); - } catch (error) { - done(error); - } - }); + await reports.forEach(report => report.getMergeRequests()); } catch (error) { Cli.x(`could not fetch merge requests.`, error); } @@ -307,14 +282,7 @@ if (config.get('query').includes('merge_requests')) { Cli.list(`${Cli.fetch} Loading timelogs`); try { - await reports.forEach(async (report, done) => { - try { - await report.getTimelogs(); - done(); - } catch (error) { - done(error); - } - }); + await reports.forEach(report => report.getTimelogs()); } catch (error) { Cli.x(`could not load timelogs.`, error); } @@ -325,10 +293,7 @@ Cli.mark(); Cli.list(`${Cli.merge} Merging reports`); try { - await reports.forEach((report, done) => { - master.merge(report); - done(); - }); + await reports.forEach(report => master.merge(report)); } catch (error) { Cli.x(`could not merge reports.`, error); } diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 574c33d..3996b5c 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -59,7 +59,7 @@ class Timekeeper { this.sync.resources = {} // resolve issues and merge requests - return this.sync.frames.forEach(async (frame, done) => { + return this.sync.frames.forEach(async frame => { let project = frame.project, type = frame.resource.type, id = frame.resource.id; @@ -73,17 +73,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, {}); try { await this.sync.resources[project][type][id].make(project, id, frame.resource.new); - if (callback) callback(); - done(); } catch (error) { - done(new Error(`Could not resolve ${type} ${id} on "${project}": ${error.message ?? error}`)); + throw new Error(`Could not resolve ${type} ${id} on "${project}": ${error.message ?? error}`); } + + if (callback) callback(); }) } @@ -91,7 +91,7 @@ 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; @@ -99,12 +99,11 @@ class Timekeeper { 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(async (frame, done) => { + return this.sync.frames.forEach(async frame => { let time = frame.duration, project = frame.project, type = frame.resource.type, @@ -115,11 +114,11 @@ class Timekeeper { try { await this._addTime(frame, time); - if (callback) callback(); - done(); } catch (error) { - done(new Error(`Could not update ${type} ${id} on ${project}: ${error.message ?? error}`)); + throw new Error(`Could not update ${type} ${id} on ${project}: ${error.message ?? error}`); } + + if (callback) callback(); }); } @@ -164,8 +163,8 @@ class Timekeeper { times = {}; await new FrameCollection(this.config) - .forEach((frame, done) => { - if (frame.stop === false) return done(); + .forEach(frame => { + if (frame.stop === false) return; let date = frame.date.format('YYYY-MM-DD'); if (!frames[date]) frames[date] = []; @@ -173,8 +172,6 @@ class Timekeeper { frames[date].push(frame); times[date] += Math.ceil(frame.duration); - - done(); }); return {frames, times}; @@ -188,9 +185,8 @@ class Timekeeper { let frames = []; await new FrameCollection(this.config) - .forEach((frame, done) => { + .forEach(frame => { frames.push(frame); - done(); }); return {frames}; diff --git a/tasks.md b/tasks.md index c3f1bdb..2f34af0 100644 --- a/tasks.md +++ b/tasks.md @@ -24,7 +24,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. the report parallel loop (only safe because runners = 1); pass the project into `Report` explicitly. Drop unused `EventEmitter` inheritance; reconsider magic special cases in `config.get()`. -- [ ] Convert callback-style `forEach(item, done)` in `FrameCollection` / +- [x] Convert callback-style `forEach(item, done)` in `FrameCollection` / `ReportCollection` to async iteration; removes try/done boilerplate. - [ ] Minor: capitalize class names (`config`, `frame`, `cli`); move `ReportCollection`'s module-level `projlist` into the instance From e53036e4422ab1bf46310b126bcb1ea4150ea403 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:40:24 +0200 Subject: [PATCH 22/72] refactor: capitalize class names, move projlist into ReportCollection Class declarations now follow the PascalCase convention already used by GitlabClient/Output/Timekeeper (default exports, so no import changes; output classes are named TableOutput/CsvOutput/etc. to avoid clashing with their Table/Csv imports). ReportCollection's module-level projlist becomes an instance property, so the de-duplication no longer leaks across collection instances. Co-Authored-By: Claude Fable 5 --- src/core/cli.js | 62 +++++++++++----------- src/core/config.js | 6 +-- src/core/file-config.js | 6 +-- src/core/filesystem.js | 8 +-- src/core/owner.js | 4 +- src/core/task.js | 4 +- src/core/time.js | 10 ++-- src/reporting/api/dayReport.js | 4 +- src/reporting/api/issue.js | 4 +- src/reporting/api/mergeRequest.js | 4 +- src/reporting/api/project.js | 4 +- src/reporting/api/report.js | 4 +- src/reporting/api/reportCollection.js | 10 ++-- src/reporting/output/csv.js | 4 +- src/reporting/output/invoice.js | 4 +- src/reporting/output/markdown.js | 4 +- src/reporting/output/table.js | 4 +- src/timekeeping/api/issue.js | 4 +- src/timekeeping/api/mergeRequest.js | 4 +- src/timekeeping/storage/frame.js | 8 +-- src/timekeeping/storage/frameCollection.js | 4 +- tasks.md | 2 +- 22 files changed, 84 insertions(+), 84 deletions(-) diff --git a/src/core/cli.js b/src/core/cli.js index a6ea30e..8a698fa 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -7,9 +7,9 @@ import progress from 'progress'; spinner.set('|/-\\'); /** - * cli helper + * Cli helper */ -class cli { +class Cli { constructor(args) { this.args = args; this.data = []; @@ -66,7 +66,7 @@ class cli { * @param string */ static out(string) { - if (cli.quiet) return; + if (Cli.quiet) return; process.stdout.write(string); } @@ -74,7 +74,7 @@ class cli { * print done message */ static done() { - cli.out(`\n${cli.party} Finished!\n`.green); + Cli.out(`\n${Cli.party} Finished!\n`.green); } /** @@ -82,7 +82,7 @@ class cli { * @param message */ static warn(message) { - cli.out(` Warning: ${message} `.bgWhite.black + "\n"); + Cli.out(` Warning: ${message} `.bgWhite.black + "\n"); } /** @@ -92,9 +92,9 @@ 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(), @@ -106,13 +106,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(); } /** @@ -120,19 +120,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 }); } @@ -141,7 +141,7 @@ class cli { * advance an existing bar */ static advance() { - cli.tick(1); + Cli.tick(1); } /** @@ -150,14 +150,14 @@ class cli { * @returns {*} */ static list(message) { - cli.resolve(false); + Cli.resolve(false); this.active = {message: `\r${message}... `.bold.grey}; this.active.interval = setInterval(() => { - cli.out(cli.active.message + spinner.next().bold.blue); + Cli.out(Cli.active.message + spinner.next().bold.blue); }, 100); - return cli.promise(); + return Cli.promise(); } /** @@ -165,10 +165,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}` + `✓\n`.green); - return cli.promise(); + return Cli.promise(); } /** @@ -178,11 +178,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}` + `✗\n`.red); - if (message) cli.error(message, error); - return cli.promise(); + if (message) Cli.error(message, error); + return Cli.promise(); } /** @@ -190,7 +190,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); } /** @@ -200,15 +200,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: ${message.red}` + '\n'); + if (error && Cli.verbose) console.log(error); process.exit(1); } @@ -259,4 +259,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/core/config.js b/src/core/config.js index d8376aa..8448819 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -48,7 +48,7 @@ const defaults = { /** * basic config */ -class config { +class Config { /** * construct */ @@ -63,7 +63,7 @@ class config { * @param key * @param value * @param force - * @returns {config} + * @returns {Config} */ set(key, value, force = false) { if (!force && (value === null || value === undefined)) return this; @@ -100,4 +100,4 @@ class config { } } -export default config; +export default Config; diff --git a/src/core/file-config.js b/src/core/file-config.js index 2db94c3..f00e09d 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -import config from './config.js'; +import Config from './config.js'; import yaml from 'read-yaml'; import hash from 'hash-sum'; import Fs from './filesystem.js'; @@ -11,7 +11,7 @@ import envPaths from 'env-paths'; /** * file config with local and global configuration files */ -class fileConfig extends config { +class FileConfig extends Config { /** * construct * @param workDir @@ -156,4 +156,4 @@ class fileConfig extends config { } } -export default fileConfig; +export default FileConfig; diff --git a/src/core/filesystem.js b/src/core/filesystem.js index 163e735..3c7b23e 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -3,7 +3,7 @@ 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); } @@ -37,7 +37,7 @@ class filesystem { } static newest(dir) { - let files = filesystem.readDir(dir); + 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); @@ -46,7 +46,7 @@ class filesystem { static all(dir) { let ctime = file => fs.statSync(path.join(dir, file.name)).ctime; - return filesystem.readDir(dir).sort((a, b) => ctime(a) - ctime(b)); + return Filesystem.readDir(dir).sort((a, b) => ctime(a) - ctime(b)); } static readDir(dir) { @@ -54,4 +54,4 @@ class filesystem { } } -export default filesystem; +export default Filesystem; diff --git a/src/core/owner.js b/src/core/owner.js index b3a2ce0..761aaf7 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -4,7 +4,7 @@ import parallel from './parallel.js'; /** * owner model */ -class owner { +class Owner { constructor(config, client = new GitlabClient(config)) { this.config = config; this.client = client; @@ -127,4 +127,4 @@ class owner { } } -export default owner; +export default Owner; diff --git a/src/core/task.js b/src/core/task.js index bf8b300..6d9ae8e 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -7,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; @@ -102,4 +102,4 @@ class task { } } -export default task; +export default Task; diff --git a/src/core/time.js b/src/core/time.js index 6d522eb..41d6238 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -15,7 +15,7 @@ Number.prototype.padLeft = function (n, str) { /** * time model */ -class time { +class Time { /** * construct * @param timeString @@ -33,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); } /* @@ -64,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); } /** @@ -121,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); @@ -179,4 +179,4 @@ class time { } } -export default time; +export default Time; 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 ce438ee..8ab789c 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -8,7 +8,7 @@ import Project from './project.js'; /** * report model */ -class report { +class Report { /** * constructor. * @param config @@ -289,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 5e3f914..6d13e50 100755 --- a/src/reporting/api/reportCollection.js +++ b/src/reporting/api/reportCollection.js @@ -1,11 +1,11 @@ import parallel from '../../core/parallel.js'; -let projlist = []; -class reportCollection { +class ReportCollection { constructor(config) { this.config = config; this.reports = []; + this.projectNames = []; } forEach(iterator) { @@ -13,8 +13,8 @@ class reportCollection { } 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/output/csv.js b/src/reporting/output/csv.js index e1352bd..2054b88 100755 --- a/src/reporting/output/csv.js +++ b/src/reporting/output/csv.js @@ -6,7 +6,7 @@ import Output from './base.js'; /** * csv output */ -class csv extends Output { +class CsvOutput extends Output { makeStats() { let stats = [[], []]; @@ -99,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 388a087..13fcdd7 100644 --- a/src/reporting/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -10,7 +10,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; @@ -266,4 +266,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 04df776..0ff72dc 100755 --- a/src/reporting/output/markdown.js +++ b/src/reporting/output/markdown.js @@ -9,7 +9,7 @@ const format = { /** * stdout table output */ -class markdown extends Output { +class MarkdownOutput extends Output { constructor(config, report) { super(config, report); this.format = format; @@ -67,4 +67,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 3d924a7..eb57209 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -10,7 +10,7 @@ const format = { /** * stdout table output */ -class table extends Output { +class TableOutput extends Output { constructor(config, report) { super(config, report); this.format = format; @@ -94,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/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/storage/frame.js b/src/timekeeping/storage/frame.js index 3c66ee8..2848350 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -4,7 +4,7 @@ import moment from 'moment-timezone'; 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) { @@ -155,4 +155,4 @@ class frame { } } -export default frame; +export default Frame; diff --git a/src/timekeeping/storage/frameCollection.js b/src/timekeeping/storage/frameCollection.js index a333fe1..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; @@ -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/tasks.md b/tasks.md index 2f34af0..233669b 100644 --- a/tasks.md +++ b/tasks.md @@ -26,7 +26,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. reconsider magic special cases in `config.get()`. - [x] Convert callback-style `forEach(item, done)` in `FrameCollection` / `ReportCollection` to async iteration; removes try/done boilerplate. -- [ ] Minor: capitalize class names (`config`, `frame`, `cli`); move +- [x] Minor: capitalize class names (`config`, `frame`, `cli`); move `ReportCollection`'s module-level `projlist` into the instance (currently leaks state across instances). From 7077138d32f520d04567f45d4a00474397d30bf1 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:43:12 +0200 Subject: [PATCH 23/72] refactor: replace moment/moment-timezone with dayjs All date handling goes through src/core/dayjs.js, which configures the utc, timezone, relativeTime (fromNow) and advancedFormat ('Do' token) plugins once. moment is in maintenance mode and was the heaviest dependency in the pkg bundle; dayjs is API-compatible for everything we use (format/startOf/endOf/subtract/add/diff/isValid/isSame/isBefore/ tz/clone/fromNow, ISO JSON serialization, Sunday week start). The GraphQL date format 'Y-M-D' becomes 'YYYY-M-D' since single 'Y' is not a dayjs token; moment-only suppressDeprecationWarnings knobs are gone. Co-Authored-By: Claude Fable 5 --- package-lock.json | 30 +++++++----------------------- package.json | 3 +-- spec/include/config.spec.js | 2 +- spec/models/time.title.spec.js | 10 +++++----- spec/output/base.spec.js | 8 ++++---- src/core/config.js | 6 +++--- src/core/dayjs.js | 14 ++++++++++++++ src/core/task.js | 8 ++++---- src/core/time.js | 4 ++-- src/reporting/api/report.js | 6 +++--- src/reporting/api/reportable.js | 4 ++-- src/reporting/commands/report.js | 18 +++++++++--------- src/reporting/output/base.js | 6 +++--- src/timekeeping/commands/cancel.js | 6 +++--- src/timekeeping/commands/create.js | 4 ++-- src/timekeeping/commands/edit.js | 2 +- src/timekeeping/commands/list.js | 1 - src/timekeeping/commands/log.js | 6 +++--- src/timekeeping/commands/resume.js | 6 +++--- src/timekeeping/commands/start.js | 4 ++-- src/timekeeping/commands/status.js | 6 +++--- src/timekeeping/commands/stop.js | 6 +++--- src/timekeeping/commands/sync.js | 1 - src/timekeeping/storage/frame.js | 26 +++++++++++--------------- src/timekeeping/timekeeper.js | 1 - tasks.md | 2 +- 26 files changed, 90 insertions(+), 100 deletions(-) create mode 100644 src/core/dayjs.js diff --git a/package-lock.json b/package-lock.json index be13869..2f2ecc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,12 +19,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", "markdown-table": "^3.0.4", - "moment": "^2.30.1", - "moment-timezone": "^0.6.2", "node-spinner": "^0.0.4", "open": "^11.0.0", "progress": "^2.0.3", @@ -1483,6 +1482,12 @@ "node": ">=12.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", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2559,27 +2564,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", diff --git a/package.json b/package.json index 5798825..04d7daf 100755 --- a/package.json +++ b/package.json @@ -47,12 +47,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", "markdown-table": "^3.0.4", - "moment": "^2.30.1", - "moment-timezone": "^0.6.2", "node-spinner": "^0.0.4", "open": "^11.0.0", "progress": "^2.0.3", 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/models/time.title.spec.js b/spec/models/time.title.spec.js index 77a46ae..bb46150 100644 --- a/spec/models/time.title.spec.js +++ b/spec/models/time.title.spec.js @@ -1,5 +1,5 @@ -import moment from 'moment'; +import dayjs from '../../src/core/dayjs.js'; import Config from '../../src/core/file-config.js'; import Time from '../../src/core/time.js'; import task from '../../src/core/task.js'; @@ -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 index d3530a1..4e2ad29 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -1,11 +1,11 @@ -import moment from 'moment'; +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: moment(date) }; + return { user, seconds, iid, project_namespace: project, date: dayjs(date) }; } function makeIssue({ iid = 1, labels = [], times = [], days = {}, estimate = 0, spent = 0 } = {}) { @@ -117,14 +117,14 @@ describe('calculateStats', () => { }); describe('Output.prepare', () => { - it('formats moments, replaces null/undefined and picks columns in order', () => { + 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: moment('2026-01-05T10:00:00Z'), + date: dayjs('2026-01-05T10:00:00Z'), missing: null, labels: ['a', 'b'] }, ['iid', 'date', 'missing', 'undefined_column', 'labels']); diff --git a/src/core/config.js b/src/core/config.js index 8448819..4cd3cb7 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,4 +1,4 @@ -import moment from 'moment'; +import dayjs from './dayjs.js'; import Time from './time.js'; const dates = ['from', 'to']; @@ -10,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, @@ -81,7 +81,7 @@ class Config { */ get(key, subKey = false) { if (dates.includes(key)) - return moment(this.data[key]); + return dayjs(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]; 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/task.js b/src/core/task.js index 6d9ae8e..4890433 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,4 +1,4 @@ -import moment from 'moment'; +import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; /** @@ -66,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() { @@ -82,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() { diff --git a/src/core/time.js b/src/core/time.js index 41d6238..c4ddb8d 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -1,4 +1,4 @@ -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']; @@ -48,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() { diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 8ab789c..3554980 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -1,4 +1,4 @@ -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'; @@ -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") } }; diff --git a/src/reporting/api/reportable.js b/src/reporting/api/reportable.js index c6b8eb0..d45d9c6 100644 --- a/src/reporting/api/reportable.js +++ b/src/reporting/api/reportable.js @@ -1,4 +1,4 @@ -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; import Time from '../../core/time.js'; import DayReport from './dayReport.js'; @@ -52,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]) { diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index dbe443c..df8fb31 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -1,6 +1,6 @@ import fs from 'fs'; 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'; @@ -133,20 +133,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'); diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index f6141fc..9f61c3f 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -1,5 +1,5 @@ import fs from 'fs'; -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; import calculateStats from '../stats.js'; const defaultFormats = { @@ -105,14 +105,14 @@ class Output { /** * 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/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index 253a2d4..028bf20 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 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 ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue}, started ${dayjs(frame.start).fromNow().green}`) - 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 ${frame.project.magenta} for new ${frame.resource.type} "${(frame.resource.id).blue}", started ${dayjs(frame.start).fromNow().green}`) }) }) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/commands/create.js b/src/timekeeping/commands/create.js index cbc3541..d3625af 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 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 ${config.get('project').magenta} and create ${type} "${title.blue}" at ${dayjs().format('HH:mm').green}`)) .catch(error => Cli.error(error)); } ); diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 8ef792f..57fcc1a 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -5,7 +5,7 @@ import Fs from '../../core/filesystem.js'; 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'; diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index 3869af8..3966445 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 Table from 'cli-table'; diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index e6035cb..f7073c7 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -1,6 +1,6 @@ import {Command} from 'commander'; import colors from 'colors'; -import moment from 'moment-timezone'; +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'; @@ -38,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:''}`); }); }); }; @@ -61,7 +61,7 @@ const logCli = (frames, times) => { } - console.log(`${moment(date).format('MMMM Do YYYY')} (${toHumanReadable(times[date])})`.green + dayNote); + console.log(`${dayjs(date).format('MMMM Do YYYY')} (${toHumanReadable(times[date])})`.green + dayNote); frames[date] .sort((a, b) => a.start.isBefore(b.start) ? -1 : 1) .forEach(frame => { diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index 9751352..95be481 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 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 ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${frame.note?frame.note:''} at ${dayjs().format('HH:mm').green}`)) .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'); diff --git a/src/timekeeping/commands/start.js b/src/timekeeping/commands/start.js index 6db0e98..2d9b369 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 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 ${config.get('project').magenta} ${type.blue} ${('#' + id).blue} at ${dayjs().format('HH:mm').green}`)) .catch(error => Cli.error(error)); } ); diff --git a/src/timekeeping/commands/status.js b/src/timekeeping/commands/status.js index 80c4804..fd63085 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 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(`${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${dayjs(frame.start).fromNow().green} (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 ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${frame.note?frame.note:''} is running, started ${dayjs(frame.start).fromNow().green} (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..d184480 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 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 ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue}, started ${dayjs(frame.start).fromNow().green} (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 ${frame.project.magenta} for new ${frame.resource.type} "${(frame.resource.id).blue}", started ${dayjs(frame.start).fromNow().green} (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 2848350..14712ce 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -1,6 +1,6 @@ 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(); @@ -49,22 +49,18 @@ class Frame { } validate() { - moment.suppressDeprecationWarnings = true; - - if(!moment(this._start).isValid()) + if(!dayjs(this._start).isValid()) throw new Error(`Start date is not in a valid ISO date format!`); - if(this._stop && !moment(this._stop).isValid()) + if(this._stop && !dayjs(this._stop).isValid()) throw new Error(`Stop date is not in a valid ISO date format!`); - - moment.suppressDeprecationWarnings = false; } _getCurrentDate() { if(this.timezone) - return moment().tz(this.timezone).format(); + return dayjs().tz(this.timezone).format(); - return moment(); + return dayjs(); } startMe() { @@ -97,7 +93,7 @@ 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")); @@ -105,7 +101,7 @@ class Frame { } get duration() { - return moment(this.stop).diff(this.start) / 1000; + return dayjs(this.stop).diff(this.start) / 1000; } get date() { @@ -113,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(); } diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 3996b5c..4446001 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -1,4 +1,3 @@ -import moment from 'moment'; import Fs from '../core/filesystem.js'; import Frame from './storage/frame.js'; import Issue from './api/issue.js'; diff --git a/tasks.md b/tasks.md index 233669b..8508e49 100644 --- a/tasks.md +++ b/tasks.md @@ -32,7 +32,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. ## Tech stack -- [ ] Replace `moment` + `moment-timezone` with Luxon or dayjs +- [x] Replace `moment` + `moment-timezone` with Luxon or dayjs (biggest binary-size win for the pkg build). - [ ] Drop `async` dependency: only `eachLimit` is used — replace with a small native concurrency limiter or `p-limit`. From 3065f59913cbcdce0322987b03a71247f8dfe4eb Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:44:30 +0200 Subject: [PATCH 24/72] refactor: replace the async library with a native concurrency limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only eachLimit was used; parallel() now runs N runner loops pulling from a shared queue, stops starting new tasks after the first failure and rejects with that error — same contract as before, one dependency less. Covered by a new spec (all tasks processed, concurrency bound, fail-fast, sync workers, empty input). Co-Authored-By: Claude Fable 5 --- package-lock.json | 7 ---- package.json | 1 - spec/include/parallel.spec.js | 70 +++++++++++++++++++++++++++++++++++ src/core/parallel.js | 31 +++++++++++----- tasks.md | 2 +- 5 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 spec/include/parallel.spec.js diff --git a/package-lock.json b/package-lock.json index 2f2ecc2..dc69e0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@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", @@ -993,12 +992,6 @@ "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", diff --git a/package.json b/package.json index 04d7daf..6f0e0b8 100755 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@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", 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/src/core/parallel.js b/src/core/parallel.js index db75308..11ae70b 100644 --- a/src/core/parallel.js +++ b/src/core/parallel.js @@ -1,19 +1,30 @@ -import async from 'async'; - /** * run the given worker over the given tasks with a bounded number of - * concurrent runners. Rejects on the first worker error. + * concurrent runners. Rejects with the first worker error; no new tasks + * are started after a failure. * @param tasks iterable of work items * @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, async task => worker(task), 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/tasks.md b/tasks.md index 8508e49..8d04488 100644 --- a/tasks.md +++ b/tasks.md @@ -34,7 +34,7 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. - [x] Replace `moment` + `moment-timezone` with Luxon or dayjs (biggest binary-size win for the pkg build). -- [ ] Drop `async` dependency: only `eachLimit` is used — replace with a small +- [x] Drop `async` dependency: only `eachLimit` is used — replace with a small native concurrency limiter or `p-limit`. - [ ] Replace `colors` (abandoned, 2022 sabotage incident) with `picocolors` or `chalk`. From 174f4aefe4a27d4592eeae31cd6a51a5c46cb907 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:47:25 +0200 Subject: [PATCH 25/72] refactor: replace colors with picocolors colors is unmaintained (and had the 2022 sabotage release); it also worked by patching String.prototype, which let several files use .red/ .magenta without importing anything. picocolors is explicit function calls (pc.red(x)), so every consumer now imports it directly. Co-Authored-By: Claude Fable 5 --- package-lock.json | 12 +----------- package.json | 2 +- src/core/cli.js | 18 +++++++++--------- src/reporting/commands/report.js | 3 ++- src/reporting/output/invoice.js | 3 ++- src/reporting/output/markdown.js | 3 ++- src/reporting/output/table.js | 12 ++++++------ src/timekeeping/commands/cancel.js | 6 +++--- src/timekeeping/commands/create.js | 4 ++-- src/timekeeping/commands/delete.js | 3 ++- src/timekeeping/commands/edit.js | 9 +++++---- src/timekeeping/commands/list.js | 4 ++-- src/timekeeping/commands/log.js | 18 +++++++++--------- src/timekeeping/commands/resume.js | 10 +++++----- src/timekeeping/commands/start.js | 4 ++-- src/timekeeping/commands/status.js | 6 +++--- src/timekeeping/commands/stop.js | 6 +++--- tasks.md | 4 ++-- 18 files changed, 61 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc69e0a..05c02b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,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", @@ -25,6 +24,7 @@ "markdown-table": "^3.0.4", "node-spinner": "^0.0.4", "open": "^11.0.0", + "picocolors": "^1.1.1", "progress": "^2.0.3", "read-yaml": "^1.1.0", "swissqrbill": "^4.3.0", @@ -1426,15 +1426,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", @@ -2766,7 +2757,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": { diff --git a/package.json b/package.json index 6f0e0b8..b7d865e 100755 --- a/package.json +++ b/package.json @@ -43,7 +43,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", @@ -53,6 +52,7 @@ "markdown-table": "^3.0.4", "node-spinner": "^0.0.4", "open": "^11.0.0", + "picocolors": "^1.1.1", "progress": "^2.0.3", "read-yaml": "^1.1.0", "swissqrbill": "^4.3.0", diff --git a/src/core/cli.js b/src/core/cli.js index 8a698fa..eace9f6 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -1,4 +1,4 @@ -import colors from 'colors'; +import pc from 'picocolors'; import confirm from '@inquirer/confirm'; import spinnerFactory from 'node-spinner'; const spinner = spinnerFactory(); @@ -74,7 +74,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`)); } /** @@ -82,7 +82,7 @@ class Cli { * @param message */ static warn(message) { - Cli.out(` Warning: ${message} `.bgWhite.black + "\n"); + Cli.out(pc.bgWhite(pc.black(` Warning: ${message} `)) + "\n"); } /** @@ -98,7 +98,7 @@ class Cli { 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, @@ -152,9 +152,9 @@ class Cli { static list(message) { 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(); @@ -166,7 +166,7 @@ class Cli { */ static mark() { Cli.resolve(); - if (Cli.active) Cli.out(`${Cli.active.message}` + `✓\n`.green); + if (Cli.active) Cli.out(`${Cli.active.message}` + pc.green(`✓\n`)); return Cli.promise(); } @@ -179,7 +179,7 @@ class Cli { */ static x(message = false, error = false) { Cli.resolve(); - if (Cli.active) Cli.out(`${Cli.active.message}` + `✗\n`.red); + if (Cli.active) Cli.out(`${Cli.active.message}` + pc.red(`✗\n`)); if (message) Cli.error(message, error); return Cli.promise(); @@ -207,7 +207,7 @@ class Cli { message = message.message; } - Cli.out(`Error: ${message.red}` + '\n'); + Cli.out(`Error: ${pc.red(message)}` + '\n'); if (error && Cli.verbose) console.log(error); process.exit(1); diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index df8fb31..ad7da93 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -1,4 +1,5 @@ import fs from 'fs'; +import pc from 'picocolors'; import {Command} from 'commander'; import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; @@ -234,7 +235,7 @@ try { }, config, 1); config.set('project', projects); - Cli.out(`\r${Cli.look} Selected projects: ${reports.reports.map(r => r.project.name.bold.blue).join(', ')}\n`); + 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 if (config.get('userColumns')) { diff --git a/src/reporting/output/invoice.js b/src/reporting/output/invoice.js index 13fcdd7..df9d36f 100644 --- a/src/reporting/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -1,6 +1,7 @@ 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`, @@ -81,7 +82,7 @@ class InvoiceOutput extends Output { stats += `\n`; if (this.projects.length > 1) { - Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${name.red}**: ${time}`); + Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${pc.red(name)}**: ${time}`); stats += `\n`; } let to = this.concat(this.config.get('invoiceAddress'), '
'); diff --git a/src/reporting/output/markdown.js b/src/reporting/output/markdown.js index 0ff72dc..549142f 100755 --- a/src/reporting/output/markdown.js +++ b/src/reporting/output/markdown.js @@ -1,5 +1,6 @@ import {markdownTable as Table} from 'markdown-table' import Output from './base.js'; +import pc from 'picocolors'; const format = { headline: h => `\n### ${h}\n`, @@ -24,7 +25,7 @@ class MarkdownOutput extends Output { stats += `\n`; if (this.projects.length > 1) { - Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${name.red}**: ${time}`); + Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${pc.red(name)}**: ${time}`); stats += `\n`; } diff --git a/src/reporting/output/table.js b/src/reporting/output/table.js index eb57209..0bab106 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -1,10 +1,10 @@ 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) }; /** @@ -21,15 +21,15 @@ class TableOutput extends Output { let stats = ''; - Object.entries(this.stats).forEach(([name, time]) => 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) { - Object.entries(this.projects).forEach(([name, time]) => stats += `\n* ${name.red}: ${time}`); + Object.entries(this.projects).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); stats += `\n`; } - Object.entries(this.users).forEach(([name, time]) => stats += `\n* ${name.red}: ${time}`); + Object.entries(this.users).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); this.write(stats.substr(1)); } diff --git a/src/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index 028bf20..d7ccc13 100755 --- a/src/timekeeping/commands/cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -1,5 +1,5 @@ import {Command} from 'commander'; -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.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 ${dayjs(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 ${dayjs(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 d3625af..fd86cbb 100755 --- a/src/timekeeping/commands/create.js +++ b/src/timekeeping/commands/create.js @@ -1,4 +1,4 @@ -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import {Command} from 'commander'; import Config from '../../core/file-config.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 ${dayjs().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 57fcc1a..489f291 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -8,6 +8,7 @@ import select from '@inquirer/checkbox'; 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; @@ -127,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 3966445..bbcf185 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -1,5 +1,5 @@ import {Command} from 'commander'; -import colors from 'colors'; +import pc from 'picocolors'; import Table from 'cli-table'; @@ -42,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 f7073c7..2ae0551 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -1,5 +1,5 @@ import {Command} from 'commander'; -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; @@ -51,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(`${dayjs(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(frame.notes.reduce((n, m) => (n + m.time), 0))) != 0; - let durationText = toSync ? toHumanReadable(frame.duration).padEnd(14).yellow : toHumanReadable(frame.duration).padEnd(14); + 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 95be481..ff7cfef 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.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 ${dayjs().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)); } @@ -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 2d9b369..5673172 100755 --- a/src/timekeeping/commands/start.js +++ b/src/timekeeping/commands/start.js @@ -1,4 +1,4 @@ -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import {Command} from 'commander'; import Config from '../../core/file-config.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 ${dayjs().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 fd63085..601c325 100755 --- a/src/timekeeping/commands/status.js +++ b/src/timekeeping/commands/status.js @@ -1,5 +1,5 @@ import {Command} from 'commander'; -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.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} ${dayjs(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 ${dayjs(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 d184480..3dcd953 100755 --- a/src/timekeeping/commands/stop.js +++ b/src/timekeeping/commands/stop.js @@ -1,5 +1,5 @@ import {Command} from 'commander'; -import colors from 'colors'; +import pc from 'picocolors'; import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.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 ${dayjs(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 ${dayjs(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/tasks.md b/tasks.md index 8d04488..8f486b4 100644 --- a/tasks.md +++ b/tasks.md @@ -36,8 +36,8 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. (biggest binary-size win for the pkg build). - [x] Drop `async` dependency: only `eachLimit` is used — replace with a small native concurrency limiter or `p-limit`. -- [ ] Replace `colors` (abandoned, 2022 sabotage incident) with - `picocolors` or `chalk`. +- [x] Replace `colors` (abandoned, 2022 sabotage incident) with + `picocolors`. - [ ] Replace `read-yaml` (ancient wrapper) with `js-yaml` directly; replace `node-spinner` 0.0.4 with a maintained alternative. - [ ] Align esbuild target (`node20`) with `engines`/pkg (`node24`). From c4afaa57857cadc04a4ccd950e625b45d408e7ca Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:48:10 +0200 Subject: [PATCH 26/72] refactor: drop read-yaml and node-spinner dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read-yaml was a decade-old wrapper; FileConfig now uses js-yaml's load with fs.readFileSync directly (same semantics, including undefined for empty files). node-spinner 0.0.4 is replaced by a four-frame local spinner in cli.js — the only thing it was used for. Co-Authored-By: Claude Fable 5 --- package-lock.json | 126 +++++++++------------------------------- package.json | 3 +- src/core/cli.js | 7 ++- src/core/file-config.js | 10 ++-- tasks.md | 4 +- 5 files changed, 41 insertions(+), 109 deletions(-) diff --git a/package-lock.json b/package-lock.json index 05c02b6..48b94d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,11 @@ "env-paths": "^4.0.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", + "js-yaml": "^5.2.1", "markdown-table": "^3.0.4", - "node-spinner": "^0.0.4", "open": "^11.0.0", "picocolors": "^1.1.1", "progress": "^2.0.3", - "read-yaml": "^1.1.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", "throttled-queue": "^3.0.0" @@ -989,7 +988,6 @@ "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/b4a": { @@ -1746,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", @@ -1779,18 +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/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2086,15 +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-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2223,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", @@ -2242,7 +2205,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { @@ -2470,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", @@ -2616,15 +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/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2938,41 +2915,6 @@ "node": ">=0.10.0" } }, - "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/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3225,12 +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/stream-meter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", @@ -3649,12 +3585,6 @@ "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", diff --git a/package.json b/package.json index b7d865e..bb7f847 100755 --- a/package.json +++ b/package.json @@ -49,12 +49,11 @@ "env-paths": "^4.0.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", + "js-yaml": "^5.2.1", "markdown-table": "^3.0.4", - "node-spinner": "^0.0.4", "open": "^11.0.0", "picocolors": "^1.1.1", "progress": "^2.0.3", - "read-yaml": "^1.1.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", "throttled-queue": "^3.0.0" diff --git a/src/core/cli.js b/src/core/cli.js index eace9f6..3b70aef 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -1,10 +1,11 @@ import pc from 'picocolors'; import confirm from '@inquirer/confirm'; -import spinnerFactory from 'node-spinner'; -const spinner = spinnerFactory(); 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 diff --git a/src/core/file-config.js b/src/core/file-config.js index f00e09d..e412b30 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -2,11 +2,13 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import Config from './config.js'; -import yaml from 'read-yaml'; +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 @@ -36,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); @@ -49,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); diff --git a/tasks.md b/tasks.md index 8f486b4..6c67657 100644 --- a/tasks.md +++ b/tasks.md @@ -38,6 +38,6 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. native concurrency limiter or `p-limit`. - [x] Replace `colors` (abandoned, 2022 sabotage incident) with `picocolors`. -- [ ] Replace `read-yaml` (ancient wrapper) with `js-yaml` directly; - replace `node-spinner` 0.0.4 with a maintained alternative. +- [x] Replace `read-yaml` (ancient wrapper) with `js-yaml` directly; + replace `node-spinner` 0.0.4 with a local four-frame spinner. - [ ] Align esbuild target (`node20`) with `engines`/pkg (`node24`). From 311933f9893487c3c8d7d69f9a071b9ca5de07c5 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 00:48:21 +0200 Subject: [PATCH 27/72] build: align esbuild target with the node24 engines/pkg setting Co-Authored-By: Claude Fable 5 --- package.json | 2 +- tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bb7f847..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", diff --git a/tasks.md b/tasks.md index 6c67657..9ee8be3 100644 --- a/tasks.md +++ b/tasks.md @@ -40,4 +40,4 @@ From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. `picocolors`. - [x] Replace `read-yaml` (ancient wrapper) with `js-yaml` directly; replace `node-spinner` 0.0.4 with a local four-frame spinner. -- [ ] Align esbuild target (`node20`) with `engines`/pkg (`node24`). +- [x] Align esbuild target (`node20`) with `engines`/pkg (`node24`). From 4c9d0dc1dd356f27dfada1000549fd3a68b12316 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 12:49:22 +0200 Subject: [PATCH 28/72] test config --- Dockerfile | 2 +- spec/models/time.title.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/spec/models/time.title.spec.js b/spec/models/time.title.spec.js index bb46150..62c31e1 100644 --- a/spec/models/time.title.spec.js +++ b/spec/models/time.title.spec.js @@ -1,6 +1,6 @@ import dayjs from '../../src/core/dayjs.js'; -import Config from '../../src/core/file-config.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'; From d4f2ec6e6ef4122a3c0e9cc231d5844580134321 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 12:50:55 +0200 Subject: [PATCH 29/72] config to core --- src/{timekeeping => core}/commands/config.js | 4 ++-- src/gtt.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/{timekeeping => core}/commands/config.js (83%) 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/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 From b1666f3a86d740403f9de93681bd041185281f95 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 12:52:27 +0200 Subject: [PATCH 30/72] remove tasks, as refactoring is through --- tasks.md | 43 ------------------------------------------- 1 file changed, 43 deletions(-) delete mode 100644 tasks.md diff --git a/tasks.md b/tasks.md deleted file mode 100644 index 9ee8be3..0000000 --- a/tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -# Refactoring / Modernization Tasks - -From the design & tech-stack review (2026-07-04), branch `dropXlsOutput`. - -## Done - -- [x] Rewrite `report` command as flat async/await; fix double-`done` callbacks, - unhandled `getIssues()` rejection, resolve-after-reject flow (`31ee12e`) -- [x] Send API token only via headers, never in URL query string or POST body (`374e2f8`) -- [x] Add tests for `Output.calculate()`/`prepare()` and `Frame` persistence (`a2524cf`) - -## Design - -- [x] Throw `Error` objects instead of strings (`timekeeper.js`, `frame.js`, - `gitlab-client.js`, `frameCollection.js`); include HTTP status, path and - GitLab error body in `GitlabClient` errors. Then switch the frame specs - to idiomatic `expect(...).to.throw(/Start date/)`. -- [x] Make `frame.write()` atomic: replace `unlinkSync` + `appendFileSync` with - `writeFileSync` (or temp file + rename). -- [x] Extract billing/aggregation logic (`calculate()`) out of the `Output` - base class into a report model; presentation layer should only render. - Behavior is pinned by `spec/output/base.spec.js`. -- [x] Remove shared `Config` mutation: stop `config.set('project', ...)` inside - the report parallel loop (only safe because runners = 1); pass the - project into `Report` explicitly. Drop unused `EventEmitter` inheritance; - reconsider magic special cases in `config.get()`. -- [x] Convert callback-style `forEach(item, done)` in `FrameCollection` / - `ReportCollection` to async iteration; removes try/done boilerplate. -- [x] Minor: capitalize class names (`config`, `frame`, `cli`); move - `ReportCollection`'s module-level `projlist` into the instance - (currently leaks state across instances). - -## Tech stack - -- [x] Replace `moment` + `moment-timezone` with Luxon or dayjs - (biggest binary-size win for the pkg build). -- [x] Drop `async` dependency: only `eachLimit` is used — replace with a small - native concurrency limiter or `p-limit`. -- [x] Replace `colors` (abandoned, 2022 sabotage incident) with - `picocolors`. -- [x] Replace `read-yaml` (ancient wrapper) with `js-yaml` directly; - replace `node-spinner` 0.0.4 with a local four-frame spinner. -- [x] Align esbuild target (`node20`) with `engines`/pkg (`node24`). From 774636a1a4b9500176666e9ea27f879db05d009a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:18:10 +0200 Subject: [PATCH 31/72] better interactive frame editor (#51) closes #42 --- src/timekeeping/commands/edit.js | 201 ++++++++++++++++++------------- 1 file changed, 117 insertions(+), 84 deletions(-) diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 489f291..95a5156 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -13,23 +13,65 @@ import pc from 'picocolors'; const SHIFT_MINUTE = 1; const SHIFT_MINUTES = 15; +function column(str, n) { + if (str.length > n) { + str = str.substr(0, n - 1) + "…"; + } + return str.padEnd(n); +} + /** - * interactively edit a frame's start/stop time with keystrokes - * @param config - * @param id + * formats a frame as a single list row, shared by the interactive and + * non-interactive menus; `focus` ('start'|'stop') highlights that field, + * `edited` ('start'|'stop'|'both') colors edited field(s) red + * @param frame + * @param focus + * @param edited + * @returns {string} + */ +function formatFrameRow(frame, focus, edited) { + const startText = frame.start.clone().format("MMMM Do YYYY HH:mm"); + const stopText = frame.stop ? "to " + frame.stop.clone().format("HH:mm") : "(running)"; + const startColor = edited === "start" || edited === "both" ? pc.red : pc.green; + const stopColor = edited === "stop" || edited === "both" ? pc.red : pc.green; + const start = focus === "start" ? pc.inverse(startColor(startText)) : startColor(startText); + const stop = focus === "stop" ? pc.inverse(stopColor(stopText)) : stopColor(stopText); + const issue = `${ + pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20)) + }${column(frame.title != null ? frame.title : "", 50)}`; + return `${frame.id} ${start} ${stop}\t${pc.magenta(column(frame.project, 50))}${issue}${ + frame.note != null ? frame.note : "" + }`; +} + +/** + * interactively edit start/stop time of a list of frames inline, with keystrokes + * @param frames * @returns {Promise} */ -function interactiveEdit(config, id) { +function showInteractiveMenu(frames) { return new Promise((resolve, reject) => { - let frame = Frame.fromFile(config, Fs.join(config.frameDir, id + '.json')); - let field = 'start'; + if (frames.length === 0) { + Cli.error('No records found.'); + resolve(); + return; + } + + // fields is a flat cycle of [frameIndex, 'start'|'stop'] pairs + const fields = frames.flatMap((_, i) => [[i, 'start'], [i, 'stop']]); + let cursor = 0; + // per-frame set of edited field names ('start'/'stop') + const editedFields = frames.map(() => new Set()); function render() { Cli.out('\x1Bc'); - Cli.out(`Editing frame ${frame.id}\n\n`); - Cli.out(`${field === 'start' ? '>' : ' '} Start: ${frame.start.format('YYYY-MM-DD HH:mm')}\n`); - Cli.out(`${field === 'stop' ? '>' : ' '} Stop: ${frame.stop ? frame.stop.format('YYYY-MM-DD HH:mm') : '(running)'}\n\n`); - Cli.out('Tab: switch field ↑/↓: ±15 min Enter: save Esc/q: cancel\n'); + Cli.out('Tab/Shift+Tab: switch field ↑/↓: ±15 min (Shift: ±1 min) Enter: save all Esc/q: cancel\n\n'); + frames.forEach((frame, i) => { + const focus = fields[cursor][0] === i ? fields[cursor][1] : null; + const edited = editedFields[i]; + const editedArg = edited.has('start') && edited.has('stop') ? 'both' : edited.has('start') ? 'start' : edited.has('stop') ? 'stop' : null; + Cli.out(`${formatFrameRow(frame, focus, editedArg)}\n`); + }); } function cleanup() { @@ -44,20 +86,24 @@ function interactiveEdit(config, id) { process.exit(); } + const [frameIdx, field] = fields[cursor]; + const frame = frames[frameIdx]; + if (key.name === 'tab') { - field = field === 'start' ? 'stop' : 'start'; + cursor = key.shift ? (cursor - 1 + fields.length) % fields.length : (cursor + 1) % fields.length; } else if (key.name === 'up' || key.name === 'down') { - let shift = key.shift ? SHIFT_MINUTE: SHIFT_MINUTES; + let shift = key.shift ? SHIFT_MINUTE : SHIFT_MINUTES; const delta = key.name === 'up' ? shift : -shift; if (field === 'start') { frame.start = frame.start.clone().add(delta, 'minutes'); } else { frame.stop = (frame.stop || frame.start).clone().add(delta, 'minutes'); } + editedFields[frameIdx].add(field); } else if (key.name === 'return') { cleanup(); try { - frame.write(); + frames.forEach((frame) => frame.write()); resolve(); } catch (error) { reject(error); @@ -87,6 +133,8 @@ function edit() { .option('-f, --following ', 'edit also the following (by ctime) of the given [id]') .option('-n, --listsize ', 'list size', 30) .option('-i, --interactive', 'edit start/stop time interactively with keystrokes') + .option('--today', 'only list entries for today') + .option('--this_week', 'only list entries for this week') .action((id, opts ,program) => { let config = new Config(process.cwd()); @@ -94,93 +142,78 @@ let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeF let timekeeper = new Timekeeper(config); const listSize = program.opts().listsize; -function column(str, n){ - if(str.length > n) { - str = str.substr(0, n-1) + "…" - } - return str.padEnd(n); -} - function toHumanReadable(input) { return Time.toHumanReadable(Math.ceil(input), config.get('hoursPerDay'), timeFormat); } -function showMenu() { - timekeeper - .all() - .then(({ frames }) => { - frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); - if (id) { - let idx = frames.findIndex((fr) => fr.id == id); - let following = Number(program.opts().following); - if (!Number.isInteger(following)) { - following = 0; - } - if (idx >= 0) { - frames = frames.slice(idx, idx + following); - } - else - { - frames = []; - } +function getMenuFrames() { + return timekeeper.all().then(({ frames }) => { + frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); + if (id) { + let idx = frames.findIndex((fr) => fr.id == id); + let following = Number(program.opts().following); + if (!Number.isInteger(following)) { + following = 0; + } + if (idx >= 0) { + frames = frames.slice(idx, idx + following); } else { - frames = frames.slice(-listSize); // last listSize frames (one page of inquirer) + frames = []; } - let lastFramesDetails = frames.map((frame) => { - let issue = `${ - pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20)) - }${column(frame.title != null ? frame.title : "", 50)}`; - return { - name: - ` ${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.id, - }; - }); + } else if (program.opts().today || program.opts().this_week) { + let from = program.opts().today ? dayjs().startOf('day') : dayjs().startOf('week'); + let to = program.opts().today ? dayjs().endOf('day') : dayjs().endOf('week'); + frames = frames.filter((fr) => !fr.start.isBefore(from) && !fr.start.isAfter(to)); + } else { + frames = frames.slice(-listSize); // last listSize frames (one page of inquirer) + } + return frames; + }); +} + +function showNonInteractiveMenu(frames) { + let lastFramesDetails = frames.map((frame) => ({ + name: ` ${formatFrameRow(frame)}`, + value: frame.id, + })); + + if (lastFramesDetails.length == 0) { + Cli.error("No records found."); + return; + } - if (lastFramesDetails.length == 0) { - Cli.error("No records found."); + select({ + message: "Frame?", + default: lastFramesDetails[lastFramesDetails.length - 1].value, + choices: lastFramesDetails, + pageSize: listSize, + }).then((answers) => { + for (const answer of answers) { + if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { + Cli.error("record not found."); } else { - select({ - message: "Frame?", - default: lastFramesDetails[lastFramesDetails.length - 1].value, - choices: lastFramesDetails, - pageSize: listSize, - }).then(async (answers) => { - let didInteractiveEdit = false; - for (const answer of answers) { - if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { - Cli.error("record not found."); - } else if (program.opts().interactive) { - await interactiveEdit(config, answer); - didInteractiveEdit = true; - } else { - Fs.open(Fs.join(config.frameDir, answer + ".json")); - } - } - - if (didInteractiveEdit) { - showMenu(); - } - }); + Fs.open(Fs.join(config.frameDir, answer + ".json")); } - }) - .catch((error) => Cli.error(error)); + } + }); } if (!id || program.opts().following) { - showMenu(); + getMenuFrames() + .then((frames) => { + if (program.opts().interactive) { + return showInteractiveMenu(frames); + } else { + return showNonInteractiveMenu(frames); + } + }) + .catch((error) => Cli.error(error)); } else { if (!Fs.exists(Fs.join(config.frameDir, id + ".json"))) Cli.error("No record found."); if (program.opts().interactive) { - interactiveEdit(config, id.replace(".json", "")).catch((error) => Cli.error(error)); + let frame = Frame.fromFile(config, Fs.join(config.frameDir, id.replace(".json", "") + ".json")); + showInteractiveMenu([frame]).catch((error) => Cli.error(error)); } else { Fs.open(Fs.join(config.frameDir, id.replace(".json", "") + ".json")); } From b378a3060fc478b2f0a420f9d8b531d26f2f5016 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 13:52:35 +0200 Subject: [PATCH 32/72] optimise performance --- src/timekeeping/commands/edit.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 95a5156..ae696e8 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -148,7 +148,6 @@ function toHumanReadable(input) { function getMenuFrames() { return timekeeper.all().then(({ frames }) => { - frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); if (id) { let idx = frames.findIndex((fr) => fr.id == id); let following = Number(program.opts().following); @@ -156,6 +155,7 @@ function getMenuFrames() { following = 0; } if (idx >= 0) { + frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); frames = frames.slice(idx, idx + following); } else { frames = []; @@ -164,7 +164,11 @@ function getMenuFrames() { let from = program.opts().today ? dayjs().startOf('day') : dayjs().startOf('week'); let to = program.opts().today ? dayjs().endOf('day') : dayjs().endOf('week'); frames = frames.filter((fr) => !fr.start.isBefore(from) && !fr.start.isAfter(to)); + frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); } else { + let to = dayjs().subtract(2, 'month'); + frames = frames.filter((fr) => fr.start.isAfter(to)); + frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); frames = frames.slice(-listSize); // last listSize frames (one page of inquirer) } return frames; From c819b24bc2ea1b49c0786751dc427955d3a9b791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:12:04 +0200 Subject: [PATCH 33/72] archiver (#52) --- src/core/zip.js | 179 ++++++++++++++++++++++++++++ src/gtt.js | 2 + src/timekeeping/commands/archive.js | 55 +++++++++ src/timekeeping/timekeeper.js | 30 +++++ 4 files changed, 266 insertions(+) create mode 100644 src/core/zip.js create mode 100644 src/timekeeping/commands/archive.js diff --git a/src/core/zip.js b/src/core/zip.js new file mode 100644 index 0000000..71cbcc8 --- /dev/null +++ b/src/core/zip.js @@ -0,0 +1,179 @@ +import fs from 'fs'; +import zlib from 'zlib'; + +const METHOD_STORE = 0; +const METHOD_DEFLATE = 8; + +/** + * convert a JS Date to DOS date/time fields used by the zip format + */ +function dosDateTime(date) { + const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f); + const day = (((Math.max(date.getFullYear(), 1980) - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f); + + return {time, day}; +} + +/** + * reverse of dosDateTime + */ +function dosToDate(day, time) { + const year = ((day >> 9) & 0x7f) + 1980; + const month = ((day >> 5) & 0xf) - 1; + const date = day & 0x1f; + const hours = (time >> 11) & 0x1f; + const minutes = (time >> 5) & 0x3f; + const seconds = (time & 0x1f) * 2; + + return new Date(year, month, date, hours, minutes, seconds); +} + +/** + * minimal zip (deflate) writer, so archiving does not require + * an external dependency. + */ +class Zip { + constructor() { + this.entries = []; + } + + /** + * add a file to the archive + * @param name path of the file inside the archive, e.g. "01/abc.json" + * @param data Buffer with the file's contents + * @param date last modified date of the entry + */ + addFile(name, data, date = new Date()) { + const compressed = zlib.deflateRawSync(data); + const useCompressed = compressed.length < data.length; + + this.entries.push({ + name, + data, + payload: useCompressed ? compressed : data, + method: useCompressed ? METHOD_DEFLATE : METHOD_STORE, + crc: zlib.crc32(data), + date + }); + + return this; + } + + /** + * build the zip file as a Buffer + * @returns {Buffer} + */ + toBuffer() { + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const entry of this.entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + const {time, day} = dosDateTime(entry.date); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0x0800, 6); + localHeader.writeUInt16LE(entry.method, 8); + localHeader.writeUInt16LE(time, 10); + localHeader.writeUInt16LE(day, 12); + localHeader.writeUInt32LE(entry.crc, 14); + localHeader.writeUInt32LE(entry.payload.length, 18); + localHeader.writeUInt32LE(entry.data.length, 22); + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); + + localParts.push(localHeader, nameBuf, entry.payload); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0x0800, 8); + centralHeader.writeUInt16LE(entry.method, 10); + centralHeader.writeUInt16LE(time, 12); + centralHeader.writeUInt16LE(day, 14); + centralHeader.writeUInt32LE(entry.crc, 16); + centralHeader.writeUInt32LE(entry.payload.length, 20); + centralHeader.writeUInt32LE(entry.data.length, 24); + centralHeader.writeUInt16LE(nameBuf.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + + centralParts.push(centralHeader, nameBuf); + + offset += localHeader.length + nameBuf.length + entry.payload.length; + } + + const centralSize = centralParts.reduce((n, b) => n + b.length, 0); + const centralOffset = offset; + + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(this.entries.length, 8); + end.writeUInt16LE(this.entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralOffset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, ...centralParts, end]); + } + + /** + * write the zip archive to disk atomically + * @param file + */ + write(file) { + const tmpFile = `${file}.tmp`; + + fs.writeFileSync(tmpFile, this.toBuffer()); + fs.renameSync(tmpFile, file); + } + + /** + * read an existing zip archive (as produced by this class) back into + * a Zip instance, so new files can be appended to it. Returns an + * empty Zip if the file does not exist. + * @param file + * @returns {Zip} + */ + static fromFile(file) { + const zip = new Zip(); + + if (!fs.existsSync(file)) return zip; + + const buffer = fs.readFileSync(file); + let offset = 0; + + while (offset < buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const method = buffer.readUInt16LE(offset + 8); + const time = buffer.readUInt16LE(offset + 10); + const day = buffer.readUInt16LE(offset + 12); + const crc = buffer.readUInt32LE(offset + 14); + const compressedSize = buffer.readUInt32LE(offset + 18); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + + const nameStart = offset + 30; + const name = buffer.toString('utf8', nameStart, nameStart + nameLength); + const dataStart = nameStart + nameLength + extraLength; + const payload = buffer.subarray(dataStart, dataStart + compressedSize); + const data = method === METHOD_DEFLATE ? zlib.inflateRawSync(payload) : Buffer.from(payload); + + zip.entries.push({name, data, payload: Buffer.from(payload), method, crc, date: dosToDate(day, time)}); + + offset = dataStart + compressedSize; + } + + return zip; + } +} + +export default Zip; diff --git a/src/gtt.js b/src/gtt.js index cd2a84c..70805d0 100755 --- a/src/gtt.js +++ b/src/gtt.js @@ -16,6 +16,7 @@ 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 archive from './timekeeping/commands/archive.js'; import report from './reporting/commands/report.js'; program @@ -31,6 +32,7 @@ program .addCommand(sync()) .addCommand(edit()) .addCommand(delCmd()) + .addCommand(archive()) .addCommand(report()) .addCommand(config()) .parse(process.argv); diff --git a/src/timekeeping/commands/archive.js b/src/timekeeping/commands/archive.js new file mode 100644 index 0000000..a7b2c9d --- /dev/null +++ b/src/timekeeping/commands/archive.js @@ -0,0 +1,55 @@ +import fs from 'fs'; +import {Command} from 'commander'; +import Config from '../../core/file-config.js'; +import Cli from '../../core/cli.js'; +import Fs from '../../core/filesystem.js'; +import Zip from '../../core/zip.js'; +import Timekeeper from '../timekeeper.js'; + +function archive() { + const archive = new Command('archive', 'archive synced time records into yearly zip files (filesYYYY.zip), one folder per month') + .option('--year ', 'only archive the given year') + .option('--verbose', 'show verbose output') + .action((options, program) => { + +Cli.verbose = program.opts().verbose; + +let config = new Config(process.cwd()), + timekeeper = new Timekeeper(config), + year = program.opts().year; + +timekeeper.archiveInit() + .then(grouped => { + let years = year ? Object.keys(grouped).filter(y => y === year) : Object.keys(grouped); + + if (years.length === 0) { + console.log('Nothing to archive.'); + return; + } + + years.forEach(archiveYear => { + let file = Fs.join(config.frameDir, `files${archiveYear}.zip`), + zip = Zip.fromFile(file), + frames = []; + + Object.keys(grouped[archiveYear]).forEach(month => { + grouped[archiveYear][month].forEach(frame => { + zip.addFile(`${month}/${frame.id}.json`, fs.readFileSync(frame.file), frame.date.toDate()); + frames.push(frame); + }); + }); + + zip.write(file); + frames.forEach(frame => Fs.remove(frame.file)); + + console.log(`Archived ${frames.length} record(s) into ${file}`); + }); + }) + .catch(error => Cli.error(error)); + +} +); +return archive; +} + +export default archive; diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 4446001..2efebcb 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -49,6 +49,36 @@ class Timekeeper { this.sync.frames.filter(frame => !(Math.ceil(frame.duration) === frame.notes.reduce((n, m) => (n + m.time), 0))); } + /** + * Group all finished, synced frames by year and month (based on + * start time), for archiving. Refuses to run while frames are + * still waiting to be synced to GitLab. + * @returns {Promise} {year: {month: [frame, ...]}} + */ + async archiveInit() { + await this.syncInit(); + + if (this.sync.frames.length > 0) { + throw new Error('Not all frames are synced yet. Run `gtt sync` first.'); + } + + let grouped = {}; + + await new FrameCollection(this.config).forEach(frame => { + if (frame.stop === false) return; + + let year = frame.date.format('YYYY'), + month = frame.date.format('MM'); + + if (!grouped[year]) grouped[year] = {}; + if (!grouped[year][month]) grouped[year][month] = []; + + grouped[year][month].push(frame); + }); + + return grouped; + } + /** * Resolve merge_requests and issues * respectively. From 82fad21108cea5e5b23fd43d397d80277f74f552 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 15:19:37 +0200 Subject: [PATCH 34/72] editor --- src/timekeeping/commands/edit.js | 128 +++++++++++++++++++++++++------ src/timekeeping/storage/frame.js | 4 + 2 files changed, 108 insertions(+), 24 deletions(-) diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index ae696e8..b265eca 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -29,7 +29,7 @@ function column(str, n) { * @param edited * @returns {string} */ -function formatFrameRow(frame, focus, edited) { +function formatFrameRow(frame, focus, edited, noteOverride) { const startText = frame.start.clone().format("MMMM Do YYYY HH:mm"); const stopText = frame.stop ? "to " + frame.stop.clone().format("HH:mm") : "(running)"; const startColor = edited === "start" || edited === "both" ? pc.red : pc.green; @@ -39,9 +39,8 @@ function formatFrameRow(frame, focus, edited) { const issue = `${ pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20)) }${column(frame.title != null ? frame.title : "", 50)}`; - return `${frame.id} ${start} ${stop}\t${pc.magenta(column(frame.project, 50))}${issue}${ - frame.note != null ? frame.note : "" - }`; + const note = noteOverride != null ? noteOverride : (frame.note != null ? frame.note : ""); + return `${frame.id} ${start} ${stop}\t${pc.magenta(column(frame.project, 50))}${issue}${note}`; } /** @@ -62,15 +61,39 @@ function showInteractiveMenu(frames) { let cursor = 0; // per-frame set of edited field names ('start'/'stop') const editedFields = frames.map(() => new Set()); + // original start/stop values, used to reset a frame's edits + const originals = frames.map((frame) => ({ + start: frame.start.clone(), + stop: frame.stop ? frame.stop.clone() : null, + })); + + // index of the frame whose note is currently being edited inline, or null + let noteEditIndex = null; + let noteBuffer = ''; + + // adjustment modes, cycled with m + const modes = [ + { amount: SHIFT_MINUTES, moveAdjacent: true, label: '±15 min' }, + { amount: SHIFT_MINUTE, moveAdjacent: true, label: '±1 min' }, + { amount: SHIFT_MINUTES, moveAdjacent: false, label: '±15 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTE, moveAdjacent: false, label: '±1 min, don\'t move adjacent frame' }, + ]; + let mode = 0; function render() { Cli.out('\x1Bc'); - Cli.out('Tab/Shift+Tab: switch field ↑/↓: ±15 min (Shift: ±1 min) Enter: save all Esc/q: cancel\n\n'); + if (noteEditIndex !== null) { + Cli.out('Editing note. Enter: confirm Esc: cancel\n\n\n'); + } else { + Cli.out('←/→ (or Tab/Shift+Tab): switch field ↑/↓: switch frame +/-: adjust time m: cycle mode r: reset frame e: edit note (if empty) w: save all q: exit (ignored on unsaved changes) Esc: exit\n'); + Cli.out(`Mode: ${modes[mode].label}\n\n`); + } frames.forEach((frame, i) => { const focus = fields[cursor][0] === i ? fields[cursor][1] : null; const edited = editedFields[i]; const editedArg = edited.has('start') && edited.has('stop') ? 'both' : edited.has('start') ? 'start' : edited.has('stop') ? 'stop' : null; - Cli.out(`${formatFrameRow(frame, focus, editedArg)}\n`); + const noteOverride = noteEditIndex === i ? pc.inverse(noteBuffer + ' ') : null; + Cli.out(`${formatFrameRow(frame, focus, editedArg, noteOverride)}\n`); }); } @@ -86,35 +109,92 @@ function showInteractiveMenu(frames) { process.exit(); } + if (noteEditIndex !== null) { + if (key.name === 'return') { + frames[noteEditIndex].note = noteBuffer; + noteEditIndex = null; + noteBuffer = ''; + } else if (key.name === 'escape') { + noteEditIndex = null; + noteBuffer = ''; + } else if (key.name === 'backspace') { + noteBuffer = noteBuffer.slice(0, -1); + } else if (str && !key.ctrl && !key.meta) { + noteBuffer += str; + } + render(); + return; + } + const [frameIdx, field] = fields[cursor]; const frame = frames[frameIdx]; - if (key.name === 'tab') { - cursor = key.shift ? (cursor - 1 + fields.length) % fields.length : (cursor + 1) % fields.length; + if (key.name === 'tab' || key.name === 'left' || key.name === 'right') { + const forward = key.name === 'left' ? false : key.name === 'right' ? true : !key.shift; + cursor = forward ? (cursor + 1) % fields.length : (cursor - 1 + fields.length) % fields.length; } else if (key.name === 'up' || key.name === 'down') { - let shift = key.shift ? SHIFT_MINUTE : SHIFT_MINUTES; - const delta = key.name === 'up' ? shift : -shift; + cursor = key.name === 'down' ? (cursor + 2) % fields.length : (cursor - 2 + fields.length) % fields.length; + } else if (key.name === 'r') { + frame.start = originals[frameIdx].start.clone(); + frame.stop = originals[frameIdx].stop ? originals[frameIdx].stop.clone() : null; + editedFields[frameIdx].clear(); + } else if (key.name === 'e') { + if (frame.notes.length == 0) { + noteEditIndex = frameIdx; + noteBuffer = frame.note != null ? frame.note : ''; + } + } else if (str === 'm') { + mode = (mode + 1) % modes.length; + } else if (str === '+' || str === '-') { + const delta = str === '+' ? modes[mode].amount : -modes[mode].amount; if (field === 'start') { - frame.start = frame.start.clone().add(delta, 'minutes'); + const newStart = frame.start.clone().add(delta, 'minutes'); + if (!frame.stop || !newStart.isAfter(frame.stop)) { + const prev = frames[frameIdx - 1]; + const linked = modes[mode].moveAdjacent && prev && prev.stop && Math.abs(frame.start.diff(prev.stop, 'minutes', true)) < 1; + if (linked) { + const newPrevStop = prev.stop.clone().add(delta, 'minutes'); + if (!newPrevStop.isBefore(prev.start)) { + prev.stop = newPrevStop; + editedFields[frameIdx - 1].add('stop'); + frame.start = newStart; + } + } else if (!(prev && prev.stop && newStart.isBefore(prev.stop))) { + frame.start = newStart; + } + } } else { - frame.stop = (frame.stop || frame.start).clone().add(delta, 'minutes'); + const newStop = (frame.stop || frame.start).clone().add(delta, 'minutes'); + if (!newStop.isBefore(frame.start)) { + const next = frames[frameIdx + 1]; + const linked = modes[mode].moveAdjacent && next && frame.stop && Math.abs(next.start.diff(frame.stop, 'minutes', true)) < 1; + if (linked) { + const newNextStart = next.start.clone().add(delta, 'minutes'); + if (!(next.stop && newNextStart.isAfter(next.stop))) { + next.start = newNextStart; + editedFields[frameIdx + 1].add('start'); + frame.stop = newStop; + } + } else if (!(next && newStop.isAfter(next.start))) { + frame.stop = newStop; + } + } } editedFields[frameIdx].add(field); - } else if (key.name === 'return') { - cleanup(); - try { - frames.forEach((frame) => frame.write()); - resolve(); - } catch (error) { - reject(error); + } else if (key.name === 'w') { + frames.forEach((frame) => { + frame.write() + editedFields[frameIdx].clear(); } - return; + ); } else if (key.name === 'escape' || str === 'q') { - cleanup(); - resolve(); - return; + if(key.name === 'escape' || editedFields.reduce((a,c)=> a + c.size, 0) == 0) + { + cleanup(); + resolve(); + return; + } } - render(); } diff --git a/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index 14712ce..d8771cc 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -138,6 +138,10 @@ class Frame { return this._note; } + set note(note) { + this._note = note; + } + get file() { return path.join(this.config.frameDir, this.id + '.json'); } From 78874390de316911c9969f3105941fac53feb838 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 15:38:43 +0200 Subject: [PATCH 35/72] fix formatting crash --- src/core/time.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/time.js b/src/core/time.js index c4ddb8d..0648a7c 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -9,7 +9,7 @@ const conditionalSimpleRegex = /([0-9]*)\>(.*)/ig; const defaultRegex = /(\[\%([^\]]*)\])/ig; Number.prototype.padLeft = function (n, str) { - return Array(n - String(this).length + 1).join(str || '0') + this; + return Array(Math.max(0, n - String(this).length + 1)).join(str || '0') + this; }; /** From c8bfc8d14dafa04ef88a241403d076f6cad548fe Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 23:10:37 +0200 Subject: [PATCH 36/72] fix tz handling #54 --- src/reporting/commands/report.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index ad7da93..5f7b894 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -135,19 +135,19 @@ config if (program.opts().today) config .set('from', dayjs().startOf('day')) - .set('to', dayjs().endOf('day')); + .set('to', dayjs().add(1, 'day').startOf('day')); if (program.opts().this_week) config .set('from', dayjs().startOf('week')) - .set('to', dayjs().endOf('week')); + .set('to', dayjs().endOf('week').add(1, 'day').startOf('day')); if (program.opts().this_month) config .set('from', dayjs().startOf('month')) - .set('to', dayjs().endOf('month')); + .set('to', dayjs().endOf('month').add(1, 'day').startOf('day')); if (program.opts().last_month) config .set('from', dayjs().subtract(1, 'months').startOf('month')) - .set('to', dayjs().subtract(1, 'months').endOf('month')); + .set('to', dayjs().subtract(1, 'months').endOf('month').add(1, 'day').startOf('day')); Cli.quiet = config.get('quiet'); Cli.verbose = config.get('_verbose'); From 850cf58c7d5adcb31da5f77f385684dd1f7d630e Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 4 Jul 2026 23:11:24 +0200 Subject: [PATCH 37/72] error details when gitlab refuses to /spend #33 --- src/timekeeping/api/writable.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/timekeeping/api/writable.js b/src/timekeeping/api/writable.js index 9eb8f65..220b8b5 100644 --- a/src/timekeeping/api/writable.js +++ b/src/timekeeping/api/writable.js @@ -52,6 +52,15 @@ export default Base => class extends Base { var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); return this.client.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt + note), + }).then(response => { + // post/spend command to /api/v4/projects/X/issues/Y/notes does not work on task + // but it returns HTTP 202. we need to check the result body + if(!response.body || response.body.errors || + typeof(response.body.commands_changes) != 'object' || + Object.keys(response.body.commands_changes).length == 0) { + throw new Error(`createTime failed: ${JSON.stringify(response.body)} see https://github.com/ndu2/gitlab-time-tracker/issues/33`); + } + return response; }); } }; From 798719a7fae8e9e97fad1731d3c08fe10d082c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:05:34 +0200 Subject: [PATCH 38/72] rewrite createTime to graphql (#55) --- src/timekeeping/api/writable.js | 40 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/timekeeping/api/writable.js b/src/timekeeping/api/writable.js index 220b8b5..875b8cc 100644 --- a/src/timekeeping/api/writable.js +++ b/src/timekeeping/api/writable.js @@ -37,9 +37,10 @@ export default Base => class extends Base { } /** - * create time - * @param time - * @returns {*} + * create time with notes using graphql. + * + * works for issues, tasks, incidents and merge requests (on 19.1.1-ee) to add + * spent times with the correct date and a comment */ createTime(time, created_at, note) { if(note === null || note === undefined) { @@ -50,15 +51,30 @@ export default Base => class extends Base { } var date = new Date(created_at); var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); - return this.client.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { - body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt + note), - }).then(response => { - // post/spend command to /api/v4/projects/X/issues/Y/notes does not work on task - // but it returns HTTP 202. we need to check the result body - if(!response.body || response.body.errors || - typeof(response.body.commands_changes) != 'object' || - Object.keys(response.body.commands_changes).length == 0) { - throw new Error(`createTime failed: ${JSON.stringify(response.body)} see https://github.com/ndu2/gitlab-time-tracker/issues/33`); + const query = + `mutation($input: CreateNoteInput!) { + createNote(input: $input) { + note { + id + body + } + errors + } + }` + let noteablePath = (this._type == 'merge_requests') ? 'MergeRequest' : 'WorkItem'; + let request = { + "query": query, + "variables": { + "input": { + "noteableId": `gid://gitlab/${noteablePath}/${this.id}`, + "body": '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]' + ' ' + spentAt + note), + } + } + }; + let promise = this.client.graphQL(request); + promise.then(response => { + if(!response.body || response.body.errors) { + throw new Error(`createTime failed: ${JSON.stringify(response.body)}`); } return response; }); From 439ba2d1889be863fdf2fe62b415ecc048665723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= <63913128+ndu2@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:14:23 +0200 Subject: [PATCH 39/72] update pipeline (#56) --- .github/workflows/npmci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/npmci.yml b/.github/workflows/npmci.yml index 34317e0..0a9ddfa 100644 --- a/.github/workflows/npmci.yml +++ b/.github/workflows/npmci.yml @@ -20,8 +20,8 @@ jobs: build: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 with: node-version: 24 - run: npm ci @@ -29,7 +29,7 @@ jobs: - run: npm run-script build - name: publish if: inputs.publishbuilds - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ github.ref_name }} path: | From 187edbc56ffc3b1899fb6d29ccbd59f042e0b17f Mon Sep 17 00:00:00 2001 From: ndu Date: Sun, 5 Jul 2026 23:39:18 +0200 Subject: [PATCH 40/72] extract arg parsing from Cli into a dedicated Args class Cli mixed terminal-rendering concerns (bars, spinners, colors) with argument parsing (project/iids), even though only report.js used the parsing side. Splitting them lets Cli stay a pure static output helper. --- src/core/args.js | 46 ++++++++++++++++++++++++++++++++ src/core/cli.js | 40 --------------------------- src/reporting/commands/report.js | 7 ++--- 3 files changed, 50 insertions(+), 43 deletions(-) create mode 100644 src/core/args.js diff --git a/src/core/args.js b/src/core/args.js new file mode 100644 index 0000000..d868e6d --- /dev/null +++ b/src/core/args.js @@ -0,0 +1,46 @@ +/** + * Args helper + */ +class Args { + constructor(args) { + this.args = args; + this.data = []; + } + + /** + * parse the args and return the project argument + * @returns {*} + */ + project() { + if (!this.args[0] && !this.data.project) return null; + + if (this.data.project) return this.data.project; + + 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; + + return this.data.project = projects; + } + + /** + * parse the args and return an array of issues + * @returns {*} + */ + iids() { + if (this.data.iids) return this.data.iids; + + 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; + + return this.data.iids; + } +} + +export default Args; diff --git a/src/core/cli.js b/src/core/cli.js index 3b70aef..4256221 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -11,11 +11,6 @@ const spinner = { next: () => spinnerFrames[spinnerIndex++ % spinnerFrames.lengt * Cli helper */ class Cli { - constructor(args) { - this.args = args; - this.data = []; - } - /* * emojis */ @@ -223,41 +218,6 @@ class Cli { resolve(); }); } - - /** - * parse the args and return the project argument - * @returns {*} - */ - project() { - if (!this.args[0] && !this.data.project) return null; - - if (this.data.project) return this.data.project; - - 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; - - return this.data.project = projects; - } - - /** - * parse the args and return an array of issues - * @returns {*} - */ - iids() { - if (this.data.iids) return this.data.iids; - - 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; - - return this.data.iids; - } } export default Cli; \ No newline at end of file diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index 5f7b894..624dad3 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -3,6 +3,7 @@ import pc from 'picocolors'; import {Command} from 'commander'; import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; +import Args from '../../core/args.js'; import Config from '../../core/file-config.js'; import Report from '../api/report.js'; import Owner from '../../core/owner.js'; @@ -81,14 +82,14 @@ function report() { // init helpers let config = new Config(process.cwd()); -let cli = new Cli(program.args); +let args = new Args(program.args); // overwrite config with args and opts config .set('url', program.opts().url) .set('token', program.opts().token) - .set('project', cli.project()) - .set('iids', cli.iids()) + .set('project', args.project()) + .set('iids', args.iids()) .set('from', program.opts().from) .set('to', program.opts().to) .set('closed', program.opts().closed) From 831e6a512655a26bc9fef5218499041a27f6d5b7 Mon Sep 17 00:00:00 2001 From: ndu Date: Sun, 5 Jul 2026 23:54:51 +0200 Subject: [PATCH 41/72] refactor all to user cli.js --- src/core/file-config.js | 10 ++++------ src/reporting/output/base.js | 2 +- src/timekeeping/commands/archive.js | 4 ++-- src/timekeeping/commands/cancel.js | 4 ++-- src/timekeeping/commands/create.js | 2 +- src/timekeeping/commands/delete.js | 2 +- src/timekeeping/commands/list.js | 4 ++-- src/timekeeping/commands/log.js | 10 +++++----- src/timekeeping/commands/resume.js | 2 +- src/timekeeping/commands/start.js | 2 +- src/timekeeping/commands/status.js | 8 ++++---- src/timekeeping/commands/stop.js | 4 ++-- src/timekeeping/storage/frameCollection.js | 3 +-- 13 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/core/file-config.js b/src/core/file-config.js index e412b30..cb58488 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -6,6 +6,7 @@ import { load as yamlLoad } from 'js-yaml'; import hash from 'hash-sum'; import Fs from './filesystem.js'; import envPaths from 'env-paths'; +import Cli from './cli.js'; const readYaml = file => yamlLoad(fs.readFileSync(file, 'utf8')); @@ -40,8 +41,7 @@ class FileConfig extends Config { try { return readYaml(this.global); } catch (e) { - console.log(`Error parsing configuration: "${this.global}"`); - process.exit(1); + Cli.error(`Error parsing configuration: "${this.global}"`, e); } } @@ -60,15 +60,13 @@ class FileConfig extends Config { try { local = Object.assign(readYaml(local.extend), local); } catch (e) { - console.log(`Error parsing configuration: "${local.extend}"`); - process.exit(1); + Cli.error(`Error parsing configuration: "${local.extend}"`, e); } } return local; } catch (e) { - console.log(`Error parsing configuration: "${this.local}"`); - process.exit(1); + Cli.error(`Error parsing configuration: "${this.local}"`, e); } } diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index 9f61c3f..ca6d560 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -91,7 +91,7 @@ class Output { * render to stdout */ toStdOut() { - console.log(this.out); + process.stdout.write(`${this.out}\n`); } /** diff --git a/src/timekeeping/commands/archive.js b/src/timekeeping/commands/archive.js index a7b2c9d..62cc0b9 100644 --- a/src/timekeeping/commands/archive.js +++ b/src/timekeeping/commands/archive.js @@ -23,7 +23,7 @@ timekeeper.archiveInit() let years = year ? Object.keys(grouped).filter(y => y === year) : Object.keys(grouped); if (years.length === 0) { - console.log('Nothing to archive.'); + Cli.out('Nothing to archive.\n'); return; } @@ -42,7 +42,7 @@ timekeeper.archiveInit() zip.write(file); frames.forEach(frame => Fs.remove(frame.file)); - console.log(`Archived ${frames.length} record(s) into ${file}`); + Cli.out(`Archived ${frames.length} record(s) into ${file}\n`); }); }) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index d7ccc13..36d937f 100755 --- a/src/timekeeping/commands/cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -19,9 +19,9 @@ timekeeper.cancel() .then(frames => { frames.forEach(frame => { if(!frame.resource.new) - 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())}`) + return Cli.out(`Cancel project ${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))}, started ${pc.green(dayjs(frame.start).fromNow())}\n`) - 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())}`) + Cli.out(`Cancel project ${pc.magenta(frame.project)} for new ${frame.resource.type} "${pc.blue((frame.resource.id))}", started ${pc.green(dayjs(frame.start).fromNow())}\n`) }) }) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/commands/create.js b/src/timekeeping/commands/create.js index fd86cbb..685b284 100755 --- a/src/timekeeping/commands/create.js +++ b/src/timekeeping/commands/create.js @@ -28,7 +28,7 @@ if (!title) Cli.error('Wrong or missing title'); timekeeper.start(project, type, title) - .then(frame => console.log(`Starting project ${pc.magenta(config.get('project'))} and create ${type} "${pc.blue(title)}" at ${pc.green(dayjs().format('HH:mm'))}`)) + .then(frame => Cli.out(`Starting project ${pc.magenta(config.get('project'))} and create ${type} "${pc.blue(title)}" at ${pc.green(dayjs().format('HH:mm'))}\n`)) .catch(error => Cli.error(error)); } ); diff --git a/src/timekeeping/commands/delete.js b/src/timekeeping/commands/delete.js index 83e9d40..df92773 100755 --- a/src/timekeeping/commands/delete.js +++ b/src/timekeeping/commands/delete.js @@ -21,7 +21,7 @@ function delCmd() { } else { let frame = Frame.fromFile(config, file).stopMe(); Fs.remove(file); - console.log(`Deleting record ${pc.magenta(frame.id)}`); + Cli.out(`Deleting record ${pc.magenta(frame.id)}\n`); } } ); diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index bbcf185..9963c95 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -39,12 +39,12 @@ timekeeper.list(project, type, program.opts().closed ? 'closed' : 'opened', prog style : {compact : true, 'padding-left' : 1} }); if (tasks.length == 0) { - console.log("No issues or merge requests found."); + cli.out("No issues or merge requests found.\n"); } tasks.forEach(issue => { table.push([pc.magenta(issue.iid.toString()), pc.green(issue.title) + "\n" + pc.gray(issue.data.web_url), issue.state]) }) - console.log(table.toString()); + cli.out(`${table.toString()}\n`); }) .catch(error => cli.error(error)); diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index 2ae0551..4fd0d3c 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -33,12 +33,12 @@ function column(str, n){ } const logCSV = (frames, times) => { - console.log("frameId, project, issueid, date, starttime, endtime, duration (s), title, note"); + Cli.out("frameId, project, issueid, date, starttime, endtime, duration (s), title, note\n"); Object.keys(frames).sort().forEach(date => { 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}, ${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:''}`); + Cli.out(`${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:''}\n`); }); }); }; @@ -61,7 +61,7 @@ const logCli = (frames, times) => { } - console.log(pc.green(`${dayjs(date).format('MMMM Do YYYY')} (${toHumanReadable(times[date])})`) + dayNote); + Cli.out(pc.green(`${dayjs(date).format('MMMM Do YYYY')} (${toHumanReadable(times[date])})`) + dayNote + '\n'); frames[date] .sort((a, b) => a.start.isBefore(b.start) ? -1 : 1) .forEach(frame => { @@ -70,8 +70,8 @@ const logCli = (frames, times) => { let issue = frame.resource.new ? column(`(new ${frame.resource.type + ' "' + frame.resource.id}")`, 70).bgBlue: `${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:''}`); + Cli.out(` ${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:''}\n`); }); }); }; diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index ff7cfef..3e92261 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -19,7 +19,7 @@ function column(str, n) { function resumeFrame(timekeeper, frame) { timekeeper.resume(frame) - .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'))}`)) + .then(frame => Cli.out(`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'))}\n`)) .catch(error => Cli.error(error)); } diff --git a/src/timekeeping/commands/start.js b/src/timekeeping/commands/start.js index 5673172..8082793 100755 --- a/src/timekeeping/commands/start.js +++ b/src/timekeeping/commands/start.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 ${pc.magenta(config.get('project'))} ${pc.blue(type)} ${pc.blue(('#' + id))} at ${pc.green(dayjs().format('HH:mm'))}`)) + .then(frame => Cli.out(`Starting project ${pc.magenta(config.get('project'))} ${pc.blue(type)} ${pc.blue(('#' + id))} at ${pc.green(dayjs().format('HH:mm'))}\n`)) .catch(error => Cli.error(error)); } ); diff --git a/src/timekeeping/commands/status.js b/src/timekeeping/commands/status.js index 601c325..ac921a5 100755 --- a/src/timekeeping/commands/status.js +++ b/src/timekeeping/commands/status.js @@ -21,16 +21,16 @@ timekeeper.status() .then(frames => { if (frames.length === 0) { if (program.opts().s) { - console.log('gtt idle '); + Cli.out('gtt idle \n'); }else { - console.log('No projects are started right now.'); + Cli.out('No projects are started right now.\n'); } return; } if (program.opts().s) { - 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})`)); + frames.forEach(frame => Cli.out(`${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))} ${pc.green(dayjs(frame.start).fromNow())} (id: ${frame.id})\n`)); } else { - 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})`)); + frames.forEach(frame => Cli.out(`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})\n`)); } }) .catch(error => Cli.error('Could not read frames.', error)); diff --git a/src/timekeeping/commands/stop.js b/src/timekeeping/commands/stop.js index 3dcd953..f3ff72f 100755 --- a/src/timekeeping/commands/stop.js +++ b/src/timekeeping/commands/stop.js @@ -20,9 +20,9 @@ timekeeper.stop() .then(frames => { frames.forEach(frame => { if(!frame.resource.new) - 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})`) + return Cli.out(`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})\n`) - 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})`) + Cli.out(`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})\n`) }); }) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/storage/frameCollection.js b/src/timekeeping/storage/frameCollection.js index 5cc78d4..79332c7 100755 --- a/src/timekeeping/storage/frameCollection.js +++ b/src/timekeeping/storage/frameCollection.js @@ -12,8 +12,7 @@ class FrameCollection { try { return Frame.fromFile(this.config, Fs.join(this.config.frameDir, file.name)); } catch (e) { - console.log(e); - throw new Error(`Error parsing frame file: ${file.name}`) + throw new Error(`Error parsing frame file: ${file.name}`, { cause: e }); } }) .filter(frame => frame); From a25d946469c6ae22c2cf63644dcc27b66036bc2b Mon Sep 17 00:00:00 2001 From: ndu Date: Mon, 6 Jul 2026 18:16:34 +0200 Subject: [PATCH 42/72] enhance interactive frame editor --- src/timekeeping/commands/edit.js | 48 ++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index b265eca..74946bf 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -29,18 +29,19 @@ function column(str, n) { * @param edited * @returns {string} */ -function formatFrameRow(frame, focus, edited, noteOverride) { +function formatFrameRow(frame, focus, editedStart, editedEnd, editedNote, noteOverride) { const startText = frame.start.clone().format("MMMM Do YYYY HH:mm"); const stopText = frame.stop ? "to " + frame.stop.clone().format("HH:mm") : "(running)"; - const startColor = edited === "start" || edited === "both" ? pc.red : pc.green; - const stopColor = edited === "stop" || edited === "both" ? pc.red : pc.green; + const startColor = editedStart? pc.red : pc.green; + const stopColor = editedEnd ? pc.red : pc.green; const start = focus === "start" ? pc.inverse(startColor(startText)) : startColor(startText); const stop = focus === "stop" ? pc.inverse(stopColor(stopText)) : stopColor(stopText); const issue = `${ pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20)) }${column(frame.title != null ? frame.title : "", 50)}`; const note = noteOverride != null ? noteOverride : (frame.note != null ? frame.note : ""); - return `${frame.id} ${start} ${stop}\t${pc.magenta(column(frame.project, 50))}${issue}${note}`; + const noteDisplay = editedNote ? pc.red(note) : note; + return `${frame.id} ${start} ${stop}\t${pc.magenta(column(frame.project, 50))}${issue}${noteDisplay}`; } /** @@ -59,12 +60,13 @@ function showInteractiveMenu(frames) { // fields is a flat cycle of [frameIndex, 'start'|'stop'] pairs const fields = frames.flatMap((_, i) => [[i, 'start'], [i, 'stop']]); let cursor = 0; - // per-frame set of edited field names ('start'/'stop') + // per-frame set of edited field names ('start'/'stop'/'note') const editedFields = frames.map(() => new Set()); - // original start/stop values, used to reset a frame's edits + // original start/stop/note values, used to reset a frame's edits const originals = frames.map((frame) => ({ start: frame.start.clone(), stop: frame.stop ? frame.stop.clone() : null, + note: frame.note, })); // index of the frame whose note is currently being edited inline, or null @@ -80,20 +82,19 @@ function showInteractiveMenu(frames) { ]; let mode = 0; - function render() { + function render() { // w write does not really save all, does it? Cli.out('\x1Bc'); if (noteEditIndex !== null) { Cli.out('Editing note. Enter: confirm Esc: cancel\n\n\n'); } else { - Cli.out('←/→ (or Tab/Shift+Tab): switch field ↑/↓: switch frame +/-: adjust time m: cycle mode r: reset frame e: edit note (if empty) w: save all q: exit (ignored on unsaved changes) Esc: exit\n'); + Cli.out('←/→ (or Tab/Shift+Tab): switch field ↑/↓: switch frame +/-: adjust time m: cycle mode r: reset frame e: edit note (if not yet synced) Enter: save selected w: save all q: exit (ignored on unsaved changes) Esc: exit\n'); Cli.out(`Mode: ${modes[mode].label}\n\n`); } frames.forEach((frame, i) => { const focus = fields[cursor][0] === i ? fields[cursor][1] : null; const edited = editedFields[i]; - const editedArg = edited.has('start') && edited.has('stop') ? 'both' : edited.has('start') ? 'start' : edited.has('stop') ? 'stop' : null; const noteOverride = noteEditIndex === i ? pc.inverse(noteBuffer + ' ') : null; - Cli.out(`${formatFrameRow(frame, focus, editedArg, noteOverride)}\n`); + Cli.out(`${formatFrameRow(frame, focus, edited.has('start'), edited.has('end'), edited.has('note'), noteOverride)}\n`); }); } @@ -137,9 +138,11 @@ function showInteractiveMenu(frames) { } else if (key.name === 'r') { frame.start = originals[frameIdx].start.clone(); frame.stop = originals[frameIdx].stop ? originals[frameIdx].stop.clone() : null; + frame.note = originals[frameIdx].note; editedFields[frameIdx].clear(); } else if (key.name === 'e') { if (frame.notes.length == 0) { + editedFields[frameIdx].add('note'); noteEditIndex = frameIdx; noteBuffer = frame.note != null ? frame.note : ''; } @@ -182,11 +185,13 @@ function showInteractiveMenu(frames) { } editedFields[frameIdx].add(field); } else if (key.name === 'w') { - frames.forEach((frame) => { + frames.forEach((frame, i) => { frame.write() - editedFields[frameIdx].clear(); - } - ); + editedFields[i].clear(); + }); + } else if (key.name === 'return') { + frames[frameIdx].write() + editedFields[frameIdx].clear(); } else if (key.name === 'escape' || str === 'q') { if(key.name === 'escape' || editedFields.reduce((a,c)=> a + c.size, 0) == 0) { @@ -215,6 +220,8 @@ function edit() { .option('-i, --interactive', 'edit start/stop time interactively with keystrokes') .option('--today', 'only list entries for today') .option('--this_week', 'only list entries for this week') + .option('--day ', 'only list entries for this day') + .option('--week ', 'only list entries for the week of this day') .action((id, opts ,program) => { let config = new Config(process.cwd()); @@ -242,9 +249,20 @@ function getMenuFrames() { } } else if (program.opts().today || program.opts().this_week) { let from = program.opts().today ? dayjs().startOf('day') : dayjs().startOf('week'); - let to = program.opts().today ? dayjs().endOf('day') : dayjs().endOf('week'); + let to = program.opts().today ? dayjs().add(1, 'day').startOf('day') : dayjs().endOf('week').add(1, 'day').startOf('day'); frames = frames.filter((fr) => !fr.start.isBefore(from) && !fr.start.isAfter(to)); frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); + } else if (program.opts().day || program.opts().week) { + let userDay = dayjs.utc(program.opts().day ? program.opts().day : program.opts().week); + if(!userDay.isValid()) { + Cli.error("invalid day."); + frames = []; + } else { + let from = program.opts().day ? userDay.startOf('day') : userDay.startOf('week'); + let to = program.opts().day ? userDay.add(1, 'day').startOf('day') : userDay.endOf('week').add(1, 'day').startOf('day'); + frames = frames.filter((fr) => !fr.start.isBefore(from) && !fr.start.isAfter(to)); + frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); + } } else { let to = dayjs().subtract(2, 'month'); frames = frames.filter((fr) => fr.start.isAfter(to)); From 8634d96e96fb2f2c041da8b86767cffdc66ba498 Mon Sep 17 00:00:00 2001 From: ndu Date: Mon, 6 Jul 2026 00:13:27 +0200 Subject: [PATCH 43/72] use Cli.error only on top level (commands) --- src/core/file-config.js | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/core/file-config.js b/src/core/file-config.js index cb58488..5e21463 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -41,7 +41,8 @@ class FileConfig extends Config { try { return readYaml(this.global); } catch (e) { - Cli.error(`Error parsing configuration: "${this.global}"`, e); + e.message = `Error parsing configuration: "${this.global}": ${e.message}`; + throw e; } } @@ -50,24 +51,27 @@ class FileConfig extends Config { * @returns {Object} */ parseLocal() { + let local; try { - 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(readYaml(local.extend), local); - } catch (e) { - Cli.error(`Error parsing configuration: "${local.extend}"`, e); - } - } - - return local; + local = Object.assign({extend: true}, readYaml(this.local)); } catch (e) { - Cli.error(`Error parsing configuration: "${this.local}"`, e); + e.message = `Error parsing configuration: "${this.local}": ${e.message}`; + throw e; + } + + if (local.extend === true) { + let global = this.parseGlobal(); + local = Object.assign(global ? global : {}, local); + } else if (local.extend) { + try { + local = Object.assign(readYaml(local.extend), local); + } catch (e) { + e.message = `Error parsing configuration: "${local.extend}": ${e.message}`; + throw e; + } } + + return local; } localExists() { From e3a87fcac99be609a0992ee60c998f1abf710358 Mon Sep 17 00:00:00 2001 From: ndu Date: Mon, 6 Jul 2026 00:23:49 +0200 Subject: [PATCH 44/72] refactor config --- src/core/commands/config.js | 7 ++--- src/core/config.js | 2 +- src/core/file-config.js | 17 ++++++----- src/gtt.js | 45 ++++++++++++++++++++--------- src/reporting/commands/report.js | 5 ++-- src/timekeeping/commands/archive.js | 5 ++-- src/timekeeping/commands/cancel.js | 5 ++-- src/timekeeping/commands/create.js | 5 ++-- src/timekeeping/commands/delete.js | 5 ++-- src/timekeeping/commands/edit.js | 5 ++-- src/timekeeping/commands/list.js | 9 +++--- src/timekeeping/commands/log.js | 7 ++--- src/timekeeping/commands/resume.js | 7 ++--- src/timekeeping/commands/start.js | 5 ++-- src/timekeeping/commands/status.js | 6 ++-- src/timekeeping/commands/stop.js | 6 ++-- src/timekeeping/commands/sync.js | 9 +++--- 17 files changed, 77 insertions(+), 73 deletions(-) diff --git a/src/core/commands/config.js b/src/core/commands/config.js index 766f80b..e86f9d9 100755 --- a/src/core/commands/config.js +++ b/src/core/commands/config.js @@ -1,14 +1,13 @@ import {Command} from 'commander'; -import Config from '../file-config.js'; import Fs from '../filesystem.js'; -let config = new Config(process.cwd()); - -function cfgCmd() { +function cfgCmd(configLoader) { const cfgCmd = new Command('config', 'edit the configuration file in your default editor') .option('-l, --local', 'edit the local configuration file') .action((options, program) => { +let config = configLoader(); + if (program.opts().local) { config.assertLocalConfig(); } diff --git a/src/core/config.js b/src/core/config.js index 4cd3cb7..c53a460 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -10,7 +10,7 @@ const defaults = { token: false, project: false, from: "1970-01-01", - to: dayjs().format(), + to: dayjs().add(1, 'day').format('YYYY-MM-DD'), iids: false, closed: false, milestone: false, diff --git a/src/core/file-config.js b/src/core/file-config.js index 5e21463..02969a0 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -2,11 +2,10 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import Config from './config.js'; -import { load as yamlLoad } from 'js-yaml'; +import { load as yamlLoad, dump as yamlDump } from 'js-yaml'; import hash from 'hash-sum'; import Fs from './filesystem.js'; import envPaths from 'env-paths'; -import Cli from './cli.js'; const readYaml = file => yamlLoad(fs.readFileSync(file, 'utf8')); @@ -18,12 +17,16 @@ class FileConfig extends Config { /** * construct * @param workDir + * @param load set to false to skip parsing the config files, e.g. when + * recovering from a broken config to still resolve its paths */ - constructor(workDir) { + constructor(workDir, recover) { super(); - this.assertGlobalConfig(); + this.assertGlobalConfig(recover); this.workDir = workDir; - this.data = Object.assign(this.data, this.localExists() ? this.parseLocal() : this.parseGlobal()); + if(!recover) { + this.data = Object.assign(this.data, this.localExists() ? this.parseLocal() : this.parseGlobal()); + } if (!fs.existsSync(this.frameDir)) fs.mkdirSync(this.frameDir, { recursive: true }); this.cache = { delete: this._cacheDelete, @@ -89,7 +92,7 @@ class FileConfig extends Config { } } - assertGlobalConfig() { + assertGlobalConfig(recover) { if(!fs.existsSync(this.globalDir) && fs.existsSync(this.oldGlobalDir)) { fs.renameSync(this.oldGlobalDir, this.globalDir); } @@ -98,7 +101,7 @@ class FileConfig extends Config { 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, ''); + if (recover && !fs.existsSync(this.global)) fs.appendFileSync(this.global, yamlDump(this.data)); } assertLocalConfig() { diff --git a/src/gtt.js b/src/gtt.js index 70805d0..cdeeab7 100755 --- a/src/gtt.js +++ b/src/gtt.js @@ -4,6 +4,8 @@ import version from './version.js'; import { program } from 'commander'; +import Config from './core/file-config.js'; +import Cli from './core/cli.js'; import config from './core/commands/config.js'; import start from './timekeeping/commands/start.js'; import create from './timekeeping/commands/create.js'; @@ -19,22 +21,37 @@ import delCmd from './timekeeping/commands/delete.js'; import archive from './timekeeping/commands/archive.js'; import report from './reporting/commands/report.js'; +/** + * build the config, only called once a command actually runs + * @param recover if true, fall back to a config with unparsed data instead + * of exiting when the config file fails to parse (used by the config + * command itself, so it stays usable to fix a broken file) + * @returns {Config} + */ +function loadConfig(recover = false) { + try { + return new Config(process.cwd(), recover); + } catch (e) { + Cli.error(`${e.message}`); + } +} + program .version(version) - .addCommand(start()) - .addCommand(create()) - .addCommand(status()) - .addCommand(stop()) - .addCommand(resume()) - .addCommand(cancel()) - .addCommand(list()) - .addCommand(log()) - .addCommand(sync()) - .addCommand(edit()) - .addCommand(delCmd()) - .addCommand(archive()) - .addCommand(report()) - .addCommand(config()) + .addCommand(start(loadConfig)) + .addCommand(create(loadConfig)) + .addCommand(status(loadConfig)) + .addCommand(stop(loadConfig)) + .addCommand(resume(loadConfig)) + .addCommand(cancel(loadConfig)) + .addCommand(list(loadConfig)) + .addCommand(log(loadConfig)) + .addCommand(sync(loadConfig)) + .addCommand(edit(loadConfig)) + .addCommand(delCmd(loadConfig)) + .addCommand(archive(loadConfig)) + .addCommand(report(loadConfig)) + .addCommand(config(() => loadConfig(true))) .parse(process.argv); diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index 624dad3..9c97dca 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -4,7 +4,6 @@ import {Command} from 'commander'; import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; import Args from '../../core/args.js'; -import Config from '../../core/file-config.js'; import Report from '../api/report.js'; import Owner from '../../core/owner.js'; import ReportCollection from '../api/reportCollection.js'; @@ -28,7 +27,7 @@ function collect(val, arr) { } -function report() { +function report(configLoader) { const report = new Command('report', 'generate a report for the given project and issues') .arguments('[project] [ids...]') .option('-e --type ', 'specify the query type: project, user, group') @@ -81,7 +80,7 @@ function report() { .action(async (project, ids, options, program) => { // init helpers -let config = new Config(process.cwd()); +let config = configLoader(); let args = new Args(program.args); // overwrite config with args and opts diff --git a/src/timekeeping/commands/archive.js b/src/timekeeping/commands/archive.js index 62cc0b9..e9c3ca7 100644 --- a/src/timekeeping/commands/archive.js +++ b/src/timekeeping/commands/archive.js @@ -1,12 +1,11 @@ import fs from 'fs'; import {Command} from 'commander'; -import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; import Zip from '../../core/zip.js'; import Timekeeper from '../timekeeper.js'; -function archive() { +function archive(configLoader) { const archive = new Command('archive', 'archive synced time records into yearly zip files (filesYYYY.zip), one folder per month') .option('--year ', 'only archive the given year') .option('--verbose', 'show verbose output') @@ -14,7 +13,7 @@ function archive() { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()), +let config = configLoader(), timekeeper = new Timekeeper(config), year = program.opts().year; diff --git a/src/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index 36d937f..2a467a1 100755 --- a/src/timekeeping/commands/cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -1,18 +1,17 @@ import {Command} from 'commander'; 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'; -function cancel() { +function cancel(configLoader) { const cancel = new Command('cancel', 'cancel and discard active monitoring time') .option('--verbose', 'show verbose output') .action((options, program) => { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()); +let config = configLoader(); let timekeeper = new Timekeeper(config); timekeeper.cancel() diff --git a/src/timekeeping/commands/create.js b/src/timekeeping/commands/create.js index 685b284..13dd006 100755 --- a/src/timekeeping/commands/create.js +++ b/src/timekeeping/commands/create.js @@ -1,12 +1,11 @@ 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'; import Timekeeper from '../timekeeper.js'; -function create() { +function create(configLoader) { const create = new Command('create', 'start monitoring time for the given project and create a new issue or merge request with the given title') .arguments('[project] [title]') .option('-t, --type ', 'specify resource type: issue, merge_request') @@ -15,7 +14,7 @@ function create() { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()), +let config = configLoader(), timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', title = program.args.length === 1 ? program.args[0] : program.args[1], diff --git a/src/timekeeping/commands/delete.js b/src/timekeeping/commands/delete.js index df92773..1075b71 100755 --- a/src/timekeeping/commands/delete.js +++ b/src/timekeeping/commands/delete.js @@ -1,16 +1,15 @@ import { Command } from 'commander'; 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() { +function delCmd(configLoader) { const delCmd = new Command('delete', 'delete time record by the given id') .arguments('[id]') .action((id, opts, program) => { - let config = new Config(process.cwd()); + let config = configLoader(); if (!id && -Infinity === (id = Fs.newest(config.frameDir).name)) Cli.error('No record found.'); diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 74946bf..f71b17c 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -1,5 +1,4 @@ import {Command} from 'commander'; -import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; import Time from '../../core/time.js'; @@ -212,7 +211,7 @@ function showInteractiveMenu(frames) { }); } -function edit() { +function edit(configLoader) { const edit = new Command('edit', 'edit time record by the given id') .arguments('[id]') .option('-f, --following ', 'edit also the following (by ctime) of the given [id]') @@ -224,7 +223,7 @@ function edit() { .option('--week ', 'only list entries for the week of this day') .action((id, opts ,program) => { -let config = new Config(process.cwd()); +let config = configLoader(); let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeFormat', 'log'); let timekeeper = new Timekeeper(config); const listSize = program.opts().listsize; diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index 9963c95..5da1ac6 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -3,11 +3,10 @@ import pc from 'picocolors'; import Table from 'cli-table'; -import Config from '../../core/file-config.js'; import cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; -function list() { +function list(configLoader) { const list = new Command('list', 'list all open issues or merge requests') .arguments('[project]') .option('--verbose', 'show verbose output') @@ -20,10 +19,10 @@ function list() { .option('--token ', 'API access token') .action((aproject, options, program) => { -let config = new Config(process.cwd()) +let config = configLoader() .set('url', program.opts().url) - .set('token', program.opts().token), - timekeeper = new Timekeeper(config), + .set('token', program.opts().token); +let timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', project = program.args[0]; diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index 4fd0d3c..e1ee396 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -1,13 +1,12 @@ import {Command} from 'commander'; 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'; import Timekeeper from '../timekeeper.js'; import mergeRequest from '../api/mergeRequest.js'; -function log() { +function log(configLoader) { const log = new Command('log', 'log recorded time records') .option('--verbose', 'show verbose output') .option('--hours_per_day ', 'hours per day for human readable time formats') @@ -17,8 +16,8 @@ function log() { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()).set('hoursPerDay', program.opts().hours_per_day), - timekeeper = new Timekeeper(config), +let config = configLoader().set('hoursPerDay', program.opts().hours_per_day); +let timekeeper = new Timekeeper(config), timeFormat = config.set('timeFormat', program.opts().time_format).get('timeFormat', 'log'); function toHumanReadable(input) { diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index 3e92261..885faf9 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -1,7 +1,6 @@ import { Command } from 'commander'; 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'; import Fs from '../../core/filesystem.js'; @@ -23,7 +22,7 @@ function resumeFrame(timekeeper, frame) { .catch(error => Cli.error(error)); } -function resume() { +function resume(configLoader) { const resume = new Command('resume', 'resume monitoring time for last stopped record') .arguments('[project]') .option('--verbose', 'show verbose output') @@ -32,8 +31,8 @@ function resume() { Cli.verbose = program.opts().verbose; - let config = new Config(process.cwd()).set('project', project), - timekeeper = new Timekeeper(config); + let config = configLoader().set('project', project); + let timekeeper = new Timekeeper(config); if (!config.get('project')) Cli.error('No project set'); diff --git a/src/timekeeping/commands/start.js b/src/timekeeping/commands/start.js index 8082793..8a57b4c 100755 --- a/src/timekeeping/commands/start.js +++ b/src/timekeeping/commands/start.js @@ -1,12 +1,11 @@ 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'; import Timekeeper from '../timekeeper.js'; -function start() { +function start(configLoader) { const start = new Command('start', 'start monitoring time for the given project and resource id') .arguments('[project] [id]') .option('-t, --type ', 'specify resource type: issue, merge_request') @@ -18,7 +17,7 @@ function start() { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()), +let config = configLoader(), timekeeper = new Timekeeper(config), type = program.opts().type ? program.opts().type : 'issue', id = program.args.length === 1 ? parseInt(program.args[0]) : parseInt(program.args[1]), diff --git a/src/timekeeping/commands/status.js b/src/timekeeping/commands/status.js index ac921a5..6da9a38 100755 --- a/src/timekeeping/commands/status.js +++ b/src/timekeeping/commands/status.js @@ -1,12 +1,11 @@ import {Command} from 'commander'; 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'; -function status() { +function status(configLoader) { const status = new Command('status', 'shows if time monitoring is running') .option('--verbose', 'show verbose output') .option('-s', 'short output') @@ -14,8 +13,7 @@ function status() { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()), - timekeeper = new Timekeeper(config); +let timekeeper = new Timekeeper(configLoader()); timekeeper.status() .then(frames => { diff --git a/src/timekeeping/commands/stop.js b/src/timekeeping/commands/stop.js index f3ff72f..b7bf362 100755 --- a/src/timekeeping/commands/stop.js +++ b/src/timekeeping/commands/stop.js @@ -1,20 +1,18 @@ import {Command} from 'commander'; 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'; -function stop() { +function stop(configLoader) { const stop = new Command('stop', 'stop monitoring time') .option('--verbose', 'show verbose output') .action((options, program) => { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()), - timekeeper = new Timekeeper(config); +let timekeeper = new Timekeeper(configLoader()); timekeeper.stop() .then(frames => { diff --git a/src/timekeeping/commands/sync.js b/src/timekeeping/commands/sync.js index 2415549..67b38c3 100755 --- a/src/timekeeping/commands/sync.js +++ b/src/timekeeping/commands/sync.js @@ -1,10 +1,9 @@ import {Command} from 'commander'; -import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; import Owner from '../../core/owner.js'; -function sync() { +function sync(configLoader) { const sync = new Command('sync', 'sync local time records to GitLab') .option('--url ', 'URL to GitLabs API') .option('--token ', 'API access token') @@ -13,10 +12,10 @@ function sync() { Cli.verbose = program.opts().verbose; -let config = new Config(process.cwd()) +let config = configLoader() .set('url', program.opts().url) - .set('token', program.opts().token), -timekeeper = new Timekeeper(config), + .set('token', program.opts().token); +let timekeeper = new Timekeeper(config), owner = new Owner(config); timekeeper.syncInit() From e0b1387c0c3a142d4288db189309075fac1228f0 Mon Sep 17 00:00:00 2001 From: ndu Date: Mon, 6 Jul 2026 23:10:08 +0200 Subject: [PATCH 45/72] optimise current file pointer, name like a temp file, recover if not exist --- src/core/filesystem.js | 4 ++++ src/timekeeping/timekeeper.js | 14 ++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/filesystem.js b/src/core/filesystem.js index 3c7b23e..f084b6c 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -20,6 +20,10 @@ class Filesystem { return fs.writeFileSync(file, data); } + static truncate(file) { + return fs.truncateSync(file); + } + static open(file) { let editor = process.env.VISUAL; diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 2efebcb..422f72f 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -9,7 +9,7 @@ const classes = { merge_request: MergeRequest }; -const CURRENT_FILE = 'current.txt'; +const CURRENT_FILE = '~current.tmp'; class Timekeeper { constructor(config) { @@ -19,7 +19,7 @@ class Timekeeper { /** * Path to the pointer file that names the currently - * active (not yet stopped) frame, if any. + * active (has no contents if stopped) */ _currentFile() { return Fs.join(this.config.frameDir, CURRENT_FILE); @@ -31,8 +31,10 @@ class Timekeeper { * lookup instead of scanning every frame file. */ _currentId() { - if (!Fs.exists(this._currentFile())) return null; - + if (!Fs.exists(this._currentFile())) { + let running = new FrameCollection(this.config).frames.find(frame => frame.stop === false); + Fs.writeText(this._currentFile(), running ? running.id : ''); + } let id = Fs.readText(this._currentFile()).trim(); return id || null; @@ -267,7 +269,7 @@ class Timekeeper { 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()); + Fs.truncate(this._currentFile()); return [frame]; } @@ -284,7 +286,7 @@ class Timekeeper { let file = Fs.join(this.config.frameDir, id + '.json'); let frame = Frame.fromFile(this.config, file); Fs.remove(file); - Fs.remove(this._currentFile()); + Fs.truncate(this._currentFile()); return [frame]; } From bb25b306632a2b3f2ccda8322f6de4a20400aa8f Mon Sep 17 00:00:00 2001 From: ndu Date: Mon, 6 Jul 2026 23:17:33 +0200 Subject: [PATCH 46/72] docu update --- documentation.md | 17 +++++++++++++---- readme.md | 6 +++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/documentation.md b/documentation.md index f84142d..912720f 100644 --- a/documentation.md +++ b/documentation.md @@ -28,7 +28,7 @@ ### limitations - Time tracking for other work items (e.g. tasks, epics) is currently not supported. See also https://github.com/ndu2/gitlab-time-tracker/issues/33 +Time tracking and reporting for epics is currently not supported. ## requirements @@ -58,7 +58,7 @@ token: 01234567891011 You will need node and npm to build the project -* [node.js](https://nodejs.org/en/download) version >= 22 +* [node.js](https://nodejs.org/en/download) version 24 * [npm](https://github.com/npm/npm) @@ -197,6 +197,15 @@ gtt edit You can omit the id to edit to bring up a list of the latest records to choose from. + +**In-Console edit records** + +```shell +gtt edit -i +gtt edit -i --this_week +gtt edit -i --week 2026-02-04 +``` + **Delete a local time record by the given id:** ```shell @@ -389,7 +398,7 @@ gtt report --no_warnings gtt report --date_format="DD.MM.YYYY HH:mm:ss" ``` -*Note: [Click here](http://momentjs.com/docs/#/displaying/format/) for a further documentation on the date format.* +*Note: [Click here](https://day.js.org/docs/en/display/format) for a further documentation on the date format.* #### Set time format for the report @@ -574,7 +583,7 @@ recordColumns: userColumns: true # Date format -# Click here for format options: http://momentjs.com/docs/#/displaying/format/ +# Click here for format options: https://day.js.org/docs/en/display/format # defaults to DD.MM.YYYY HH:mm:ss dateFormat: DD.MM.YYYY HH:mm:ss diff --git a/readme.md b/readme.md index 8b5a4c9..60a84db 100755 --- a/readme.md +++ b/readme.md @@ -9,11 +9,11 @@ gtt is a fully featured command line interface for GitLab's time tracking featur ## About this fork (ndu2) -Shout-out to kris for creating this handy tool. As we use gtt regularly and maintenance on the original repo stopped, I maintain the code base in the foreseeable future on this fork (versions 1.8.x). +Shout-out to kris for creating this handy tool. As we use gtt regularly and maintenance on the original repo stopped, I maintain the code base in the foreseeable future on this fork (versions 1.8.x and later). Feel free to contact me or create a Pull Request if you have some patches or proposals. -### Where to get gtt v1.8 +### Where to get gtt There are various options: @@ -33,7 +33,7 @@ How to install and use gtt? You can find the documentation [here](documentation. ### limitations -Time tracking for other work items (e.g. tasks, epics) is currently not supported. +Time tracking and reporting for epics is currently not supported. ## license From 195e149647ba6118171f733908c5c1030daace80 Mon Sep 17 00:00:00 2001 From: ndu Date: Mon, 6 Jul 2026 23:56:15 +0200 Subject: [PATCH 47/72] cleanup model classes --- src/core/issue.js | 22 +++++ src/core/mergeRequest.js | 22 +++++ src/core/task.js | 141 ++++++++++++++++++++++++++-- src/reporting/api/issue.js | 13 --- src/reporting/api/mergeRequest.js | 13 --- src/reporting/api/report.js | 4 +- src/reporting/api/reportable.js | 89 ------------------ src/timekeeping/api/issue.js | 30 ------ src/timekeeping/api/mergeRequest.js | 29 ------ src/timekeeping/api/writable.js | 82 ---------------- src/timekeeping/commands/log.js | 1 - src/timekeeping/timekeeper.js | 4 +- 12 files changed, 180 insertions(+), 270 deletions(-) create mode 100644 src/core/issue.js create mode 100644 src/core/mergeRequest.js delete mode 100644 src/reporting/api/issue.js delete mode 100644 src/reporting/api/mergeRequest.js delete mode 100644 src/reporting/api/reportable.js delete mode 100644 src/timekeeping/api/issue.js delete mode 100644 src/timekeeping/api/mergeRequest.js delete mode 100644 src/timekeeping/api/writable.js diff --git a/src/core/issue.js b/src/core/issue.js new file mode 100644 index 0000000..fdcd227 --- /dev/null +++ b/src/core/issue.js @@ -0,0 +1,22 @@ +import CoreTask from './task.js'; +import GitlabClient from './gitlab-client.js'; + +class Issue extends CoreTask { + static resoureType = 'issues'; + + constructor(config, data, client) { + super(config, data, client, Issue.resoureType); + } + + static list(config, project, state, my, client = new GitlabClient(config)) { + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + const path = project + ? `projects/${encodeURIComponent(project)}/${Issue.resoureType}?${query}` + : `${Issue.resoureType}/?${query}`; + + return client.get(path) + .then(response => response.body.map(data => new this(config, data, client))); + } +} + +export default Issue; diff --git a/src/core/mergeRequest.js b/src/core/mergeRequest.js new file mode 100644 index 0000000..34f158a --- /dev/null +++ b/src/core/mergeRequest.js @@ -0,0 +1,22 @@ +import CoreTask from './task.js'; +import GitlabClient from './gitlab-client.js'; + +class MergeRequest extends CoreTask { + static resoureType = 'merge_requests'; + + constructor(config, data, client) { + super(config, data, client, MergeRequest.resoureType); + } + + static list(config, project, state, my, client = new GitlabClient(config)) { + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + const path = project + ? `projects/${encodeURIComponent(project)}/${MergeRequest.resoureType}?${query}` + : `${MergeRequest.resoureType}}/?${query}`; + + return client.get(path) + .then(response => response.body.map(data => new this(config, data, client))); + } +} + +export default MergeRequest; diff --git a/src/core/task.js b/src/core/task.js index 4890433..b50a026 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,12 +1,8 @@ import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; +import Time from './time.js'; +import DayReport from '../reporting/api/dayReport.js'; -/** - * task model — shared data/getters for issues and merge requests, which differ - * only by their GitLab resource type. Write ops (make/createTime) and the - * list() query live in timekeeping/api/*; read/aggregation in reporting/api/*. - * @param type the GitLab resource type: 'issues' or 'merge_requests' - */ class Task { constructor(config, data = {}, client = new GitlabClient(config), type) { this.config = config; @@ -17,9 +13,6 @@ class Task { this.type = type; } - /* - * properties - */ get iid() { return this.data.iid; } @@ -100,6 +93,136 @@ class Task { get _typeSingular() { return this.type === 'merge_requests' ? 'Merge Request' : 'Issue'; } + + make(project, id, create = false) { + let promise = create + ? this.client.post(`projects/${encodeURIComponent(project)}/${this._type}`, {title: id}) + : this.client.get(`projects/${encodeURIComponent(project)}/${this._type}/${id}`); + + return promise.then(response => { + this.data = response.body; + return this; + }); + } + + getNotes() { + let promise = this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); + promise.then(notes => this.notes = notes); + + return promise; + } + + createTime(time, created_at, note) { + if(note === null || note === undefined) { + note = ''; + } + else { + note = '\n\n' + note; + } + var date = new Date(created_at); + var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); + const query = + `mutation($input: CreateNoteInput!) { + createNote(input: $input) { + note { + id + body + } + errors + } + }` + let noteablePath = (this._type == 'merge_requests') ? 'MergeRequest' : 'WorkItem'; + let request = { + "query": query, + "variables": { + "input": { + "noteableId": `gid://gitlab/${noteablePath}/${this.id}`, + "body": '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]' + ' ' + spentAt + note), + } + } + }; + let promise = this.client.graphQL(request); + promise.then(response => { + if(!response.body || response.body.errors) { + throw new Error(`createTime failed: ${JSON.stringify(response.body)}`); + } + return response; + }); + } + + getStats() { + let promise = this.client.get(`projects/${this.data.project_id}/${this._type}/${this.iid}/time_stats`); + promise.then(response => this.stats = response.body); + + return promise; + } + + recordTimelogs(timelogs){ + let spentFreeLabels = this.config.get('freeLabels'); + if(undefined === spentFreeLabels) { + spentFreeLabels = []; + } + let spentHalfPriceLabels = this.config.get('halfPriceLabels'); + if(undefined === spentHalfPriceLabels) { + spentHalfPriceLabels = []; + } + + let free = false; + let halfPrice = false; + this.labels.forEach(label => { + spentFreeLabels.forEach(freeLabel => { + free |= (freeLabel == label); + }); + }); + this.labels.forEach(label => { + spentHalfPriceLabels.forEach(halfPriceLabel => { + halfPrice |= (halfPriceLabel == label); + }); + }); + + let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); + + let times = [], + timeSpent = 0, + timeUsers = {}, + timeFormat = this.config.get('timeFormat', this._type); + + timelogs.forEach( + (timelog) => { + let spentAt = dayjs(timelog.spentAt); + let dateGrp = spentAt.format(this.config.get('dateFormatGroupReport')); + if(!this.days[dateGrp]) + { + this.days[dateGrp] = new DayReport(this.iid, this.title, spentAt, chargeRatio); + } + if(timelog.note && timelog.note.body) { + this.days[dateGrp].addNote(timelog.note.body); + } + this.days[dateGrp].addSpent(timelog.timeSpent); + + let time = new Time(null, spentAt, { + author: {username: timelog.user.username}, + created_at: timelog.spentAt, + noteable_type: this._typeSingular + }, this, this.config); + time.seconds = timelog.timeSpent; + time.project_namespace = this.project_namespace; + + // only include times by the configured user + if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; + + if (!timeUsers[timelog.user.username]) timeUsers[timelog.user.username] = 0; + + timeSpent += time.seconds; + timeUsers[timelog.user.username] += time.seconds; + + times.push(time); + }); + + Object.entries(timeUsers).forEach(([name, time]) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); + this.timeSpent = timeSpent; + this.times = times; + } } export default Task; diff --git a/src/reporting/api/issue.js b/src/reporting/api/issue.js deleted file mode 100644 index a70f93b..0000000 --- a/src/reporting/api/issue.js +++ /dev/null @@ -1,13 +0,0 @@ -import CoreTask from '../../core/task.js'; -import reportable from './reportable.js'; - -/** - * issue with reporting read/aggregation (getStats, recordTimelogs). - */ -class Issue extends reportable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'issues'); - } -} - -export default Issue; diff --git a/src/reporting/api/mergeRequest.js b/src/reporting/api/mergeRequest.js deleted file mode 100644 index e4b7ad9..0000000 --- a/src/reporting/api/mergeRequest.js +++ /dev/null @@ -1,13 +0,0 @@ -import CoreTask from '../../core/task.js'; -import reportable from './reportable.js'; - -/** - * merge request with reporting read/aggregation (getStats, recordTimelogs). - */ -class MergeRequest extends reportable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'merge_requests'); - } -} - -export default MergeRequest; diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 3554980..9c9fb9c 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -1,8 +1,8 @@ import dayjs from '../../core/dayjs.js'; import GitlabClient from '../../core/gitlab-client.js'; import parallel from '../../core/parallel.js'; -import Issue from './issue.js'; -import MergeRequest from './mergeRequest.js'; +import Issue from '../../core/issue.js'; +import MergeRequest from '../../core/mergeRequest.js'; import Project from './project.js'; /** diff --git a/src/reporting/api/reportable.js b/src/reporting/api/reportable.js deleted file mode 100644 index d45d9c6..0000000 --- a/src/reporting/api/reportable.js +++ /dev/null @@ -1,89 +0,0 @@ -import dayjs from '../../core/dayjs.js'; -import Time from '../../core/time.js'; -import DayReport from './dayReport.js'; - -/** - * mixin: adds reporting read/aggregation to a core issue/mergeRequest class. - * @param Base a core issue/mergeRequest class - */ -export default Base => class extends Base { - /** - * set stats - * @returns {Promise} - */ - getStats() { - let promise = this.client.get(`projects/${this.data.project_id}/${this._type}/${this.iid}/time_stats`); - promise.then(response => this.stats = response.body); - - return promise; - } - - recordTimelogs(timelogs){ - - let spentFreeLabels = this.config.get('freeLabels'); - if(undefined === spentFreeLabels) { - spentFreeLabels = []; - } - let spentHalfPriceLabels = this.config.get('halfPriceLabels'); - if(undefined === spentHalfPriceLabels) { - spentHalfPriceLabels = []; - } - - let free = false; - let halfPrice = false; - this.labels.forEach(label => { - spentFreeLabels.forEach(freeLabel => { - free |= (freeLabel == label); - }); - }); - this.labels.forEach(label => { - spentHalfPriceLabels.forEach(halfPriceLabel => { - halfPrice |= (halfPriceLabel == label); - }); - }); - - - let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); - - let times = [], - timeSpent = 0, - timeUsers = {}, - timeFormat = this.config.get('timeFormat', this._type); - - timelogs.forEach( - (timelog) => { - let spentAt = dayjs(timelog.spentAt); - let dateGrp = spentAt.format(this.config.get('dateFormatGroupReport')); - if(!this.days[dateGrp]) - { - this.days[dateGrp] = new DayReport(this.iid, this.title, spentAt, chargeRatio); - } - if(timelog.note && timelog.note.body) { - this.days[dateGrp].addNote(timelog.note.body); - } - this.days[dateGrp].addSpent(timelog.timeSpent); - - let time = new Time(null, spentAt, { - author: {username: timelog.user.username}, - created_at: timelog.spentAt, - noteable_type: this._typeSingular - }, this, this.config); - time.seconds = timelog.timeSpent; - time.project_namespace = this.project_namespace; - - // only include times by the configured user - if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; - - if (!timeUsers[timelog.user.username]) timeUsers[timelog.user.username] = 0; - - timeSpent += time.seconds; - timeUsers[timelog.user.username] += time.seconds; - - times.push(time); - }); - - 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/timekeeping/api/issue.js b/src/timekeeping/api/issue.js deleted file mode 100644 index fcb79ad..0000000 --- a/src/timekeeping/api/issue.js +++ /dev/null @@ -1,30 +0,0 @@ -import CoreTask from '../../core/task.js'; -import GitlabClient from '../../core/gitlab-client.js'; -import writable from './writable.js'; - -/** - * issue with timekeeping write operations (make/createTime via the writable - * mixin). The collection-level list() query is a static — it needs no - * instance state. - */ -class Issue extends writable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'issues'); - } - - /** - * list issues, either of a single project or across all projects - * @returns {Promise} resolving to an array of issue instances - */ - static list(config, project, state, my, client = new GitlabClient(config)) { - const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; - const path = project - ? `projects/${encodeURIComponent(project)}/issues?${query}` - : `issues/?${query}`; - - return client.get(path) - .then(response => response.body.map(data => new this(config, data, client))); - } -} - -export default Issue; diff --git a/src/timekeeping/api/mergeRequest.js b/src/timekeeping/api/mergeRequest.js deleted file mode 100644 index 839a69c..0000000 --- a/src/timekeeping/api/mergeRequest.js +++ /dev/null @@ -1,29 +0,0 @@ -import CoreTask from '../../core/task.js'; -import GitlabClient from '../../core/gitlab-client.js'; -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) { - constructor(config, data, client) { - super(config, data, client, 'merge_requests'); - } - - /** - * list merge requests, either of a single project or across all projects - * @returns {Promise} resolving to an array of merge request instances - */ - static list(config, project, state, my, client = new GitlabClient(config)) { - const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; - const path = project - ? `projects/${encodeURIComponent(project)}/merge_requests?${query}` - : `merge_requests/?${query}`; - - return client.get(path) - .then(response => response.body.map(data => new this(config, data, client))); - } -} - -export default MergeRequest; diff --git a/src/timekeeping/api/writable.js b/src/timekeeping/api/writable.js deleted file mode 100644 index 875b8cc..0000000 --- a/src/timekeeping/api/writable.js +++ /dev/null @@ -1,82 +0,0 @@ -import Time from '../../core/time.js'; - -/** - * mixin: adds timekeeping write operations to a core issue/mergeRequest class: - * make() (get-or-create the resource) and createTime() (post a "/spend" note). - * The resource path segment is taken from the core class' _type getter. - * @param Base a core issue/mergeRequest class - */ -export default Base => class extends Base { - /** - * get-or-create the resource on GitLab and populate this.data - * @param project - * @param id - * @param create create the resource instead of fetching it - * @returns {Promise} - */ - make(project, id, create = false) { - let promise = create - ? this.client.post(`projects/${encodeURIComponent(project)}/${this._type}`, {title: id}) - : this.client.get(`projects/${encodeURIComponent(project)}/${this._type}/${id}`); - - return promise.then(response => { - this.data = response.body; - return this; - }); - } - - /** - * set notes - * @returns {Promise} - */ - getNotes() { - let promise = this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); - promise.then(notes => this.notes = notes); - - return promise; - } - - /** - * create time with notes using graphql. - * - * works for issues, tasks, incidents and merge requests (on 19.1.1-ee) to add - * spent times with the correct date and a comment - */ - createTime(time, created_at, note) { - if(note === null || note === undefined) { - note = ''; - } - else { - note = '\n\n' + note; - } - var date = new Date(created_at); - var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); - const query = - `mutation($input: CreateNoteInput!) { - createNote(input: $input) { - note { - id - body - } - errors - } - }` - let noteablePath = (this._type == 'merge_requests') ? 'MergeRequest' : 'WorkItem'; - let request = { - "query": query, - "variables": { - "input": { - "noteableId": `gid://gitlab/${noteablePath}/${this.id}`, - "body": '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]' + ' ' + spentAt + note), - } - } - }; - let promise = this.client.graphQL(request); - promise.then(response => { - if(!response.body || response.body.errors) { - throw new Error(`createTime failed: ${JSON.stringify(response.body)}`); - } - return response; - }); - } -}; diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index e1ee396..06e5a29 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -4,7 +4,6 @@ import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; import Time from '../../core/time.js'; import Timekeeper from '../timekeeper.js'; -import mergeRequest from '../api/mergeRequest.js'; function log(configLoader) { const log = new Command('log', 'log recorded time records') diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 422f72f..564ed88 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -1,7 +1,7 @@ import Fs from '../core/filesystem.js'; import Frame from './storage/frame.js'; -import Issue from './api/issue.js'; -import MergeRequest from './api/mergeRequest.js'; +import Issue from '../core/issue.js'; +import MergeRequest from '../core/mergeRequest.js'; import FrameCollection from './storage/frameCollection.js'; const classes = { From 2333f7ff5d737f3ad8baa4f3987cc894b0c68e4b Mon Sep 17 00:00:00 2001 From: ndu Date: Tue, 7 Jul 2026 23:08:10 +0200 Subject: [PATCH 48/72] optimise reporting time, do not re-read times. hopp schwiiz --- spec/output/base.spec.js | 3 ++- src/core/task.js | 11 ++--------- src/reporting/api/report.js | 2 -- src/reporting/stats.js | 4 ++-- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js index 4e2ad29..e9dc238 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -14,7 +14,8 @@ function makeIssue({ iid = 1, labels = [], times = [], days = {}, estimate = 0, labels, times, days, - stats: { time_estimate: estimate, total_time_spent: spent } + total_spent : spent, + total_estimate : estimate }; } diff --git a/src/core/task.js b/src/core/task.js index b50a026..ca29541 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -79,11 +79,11 @@ class Task { } get total_spent() { - return this.stats ? this.config.toHumanReadable(this.stats.total_time_spent, this._type) : null; + return this.data.time_stats ? this.config.toHumanReadable(this.data.time_stats.total_time_spent, this._type) : null; } get total_estimate() { - return this.stats ? this.config.toHumanReadable(this.stats.time_estimate, this._type) : null; + return this.data.time_stats ? this.config.toHumanReadable(this.data.time_stats.time_estimate, this._type) : null; } get _type() { @@ -150,13 +150,6 @@ class Task { }); } - getStats() { - let promise = this.client.get(`projects/${this.data.project_id}/${this._type}/${this.iid}/time_stats`); - promise.then(response => this.stats = response.body); - - return promise; - } - recordTimelogs(timelogs){ let spentFreeLabels = this.config.get('freeLabels'); if(undefined === spentFreeLabels) { diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 9c9fb9c..264eac3 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -243,8 +243,6 @@ class Report { timelog[input].iid == data.iid && timelog[input].projectId == data.project_id)); - await item.getStats(); - if (this.config.get('showWithoutTimes') || item.times.length > 0) { collect.push(item); } diff --git a/src/reporting/stats.js b/src/reporting/stats.js index c20ff77..30a8401 100644 --- a/src/reporting/stats.js +++ b/src/reporting/stats.js @@ -69,8 +69,8 @@ export default function calculateStats(config, report) { } times.push(time); }); - totalEstimate += parseInt(issue.stats.time_estimate); - totalSpent += parseInt(issue.stats.total_time_spent); + totalEstimate += parseFloat(issue.total_estimate); + totalSpent += parseFloat(issue.total_spent); }); report[type].sort((a, b) => { From e1de8a6a88bab55952e1b75f2e72e938b16d6c5f Mon Sep 17 00:00:00 2001 From: ndu Date: Wed, 8 Jul 2026 01:42:06 +0200 Subject: [PATCH 49/72] optimise sync --- src/core/task.js | 8 ++++---- src/timekeeping/timekeeper.js | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/core/task.js b/src/core/task.js index ca29541..8451890 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -141,12 +141,12 @@ class Task { } } }; - let promise = this.client.graphQL(request); - promise.then(response => { - if(!response.body || response.body.errors) { + return this.client.graphQL(request).then(response => { + let errors = response.body?.errors ?? response.body?.data?.createNote?.errors; + if(!response.body || (errors && errors.length)) { throw new Error(`createTime failed: ${JSON.stringify(response.body)}`); } - return response; + return response.body.data.createNote.note; }); } diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 564ed88..5bb5458 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -156,8 +156,15 @@ class Timekeeper { 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(); + let createdNote = await resource.createTime(Math.ceil(time), frame._stop, frame.note); + let noteid = createdNote ?.id?.split('/')?.pop(); + // fallback, if gitlab does not return the created note + if(!isNaN(noteid)) { + noteid = parseInt(noteid) + } else { + await resource.getNotes() + noteid = resource.notes[0].id; + } if (frame.resource.new) { delete frame.resource.new; @@ -166,7 +173,7 @@ class Timekeeper { } frame.notes.push({ - id: resource.notes[0].id, + id: noteid, time: Math.ceil(time) }); From 3090300d7bda32a55cb0bfaae505b9fe772dd02d Mon Sep 17 00:00:00 2001 From: ndu Date: Thu, 9 Jul 2026 19:16:35 +0200 Subject: [PATCH 50/72] edit rework keep frame edit selector open until exit --- package-lock.json | 24 -------- package.json | 1 - src/timekeeping/commands/edit.js | 98 ++++++++++++++++++-------------- 3 files changed, 54 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48b94d7..3b32ee2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.9.0-snapshot", "license": "GPL-2.0", "dependencies": { - "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/select": "^5.2.1", "camelcase": "^9.0.0", @@ -621,29 +620,6 @@ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", - "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@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/confirm": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", diff --git a/package.json b/package.json index 26607e2..88145f3 100755 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "author": "kriskbx", "license": "GPL-2.0", "dependencies": { - "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/select": "^5.2.1", "camelcase": "^9.0.0", diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index f71b17c..2bbe981 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -3,7 +3,7 @@ import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; import Time from '../../core/time.js'; import Frame from '../storage/frame.js'; -import select from '@inquirer/checkbox'; +import select, { Separator } from '@inquirer/select'; import dayjs from '../../core/dayjs.js'; import readline from 'readline'; import Timekeeper from '../timekeeper.js'; @@ -74,14 +74,16 @@ function showInteractiveMenu(frames) { // adjustment modes, cycled with m const modes = [ - { amount: SHIFT_MINUTES, moveAdjacent: true, label: '±15 min' }, - { amount: SHIFT_MINUTE, moveAdjacent: true, label: '±1 min' }, - { amount: SHIFT_MINUTES, moveAdjacent: false, label: '±15 min, don\'t move adjacent frame' }, - { amount: SHIFT_MINUTE, moveAdjacent: false, label: '±1 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTES, moveAdjacent: true, checkAdjacent: true, label: '±15 min' }, + { amount: SHIFT_MINUTE, moveAdjacent: true, checkAdjacent: true, label: '±1 min' }, + { amount: SHIFT_MINUTES, moveAdjacent: false, checkAdjacent: true, label: '±15 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTE, moveAdjacent: false, checkAdjacent: true, label: '±1 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTES, moveAdjacent: false, checkAdjacent: false, label: '±15 min, allow moving over adjacent frame' }, + { amount: SHIFT_MINUTE, moveAdjacent: false, checkAdjacent: false, label: '±1 min, allow moving over frame' }, ]; let mode = 0; - function render() { // w write does not really save all, does it? + function render() { Cli.out('\x1Bc'); if (noteEditIndex !== null) { Cli.out('Editing note. Enter: confirm Esc: cancel\n\n\n'); @@ -93,7 +95,7 @@ function showInteractiveMenu(frames) { const focus = fields[cursor][0] === i ? fields[cursor][1] : null; const edited = editedFields[i]; const noteOverride = noteEditIndex === i ? pc.inverse(noteBuffer + ' ') : null; - Cli.out(`${formatFrameRow(frame, focus, edited.has('start'), edited.has('end'), edited.has('note'), noteOverride)}\n`); + Cli.out(`${formatFrameRow(frame, focus, edited.has('start'), edited.has('stop'), edited.has('note'), noteOverride)}\n`); }); } @@ -161,7 +163,7 @@ function showInteractiveMenu(frames) { editedFields[frameIdx - 1].add('stop'); frame.start = newStart; } - } else if (!(prev && prev.stop && newStart.isBefore(prev.stop))) { + } else if (!modes[mode].checkAdjacent || !(prev && prev.stop && newStart.isBefore(prev.stop))) { frame.start = newStart; } } @@ -177,7 +179,7 @@ function showInteractiveMenu(frames) { editedFields[frameIdx + 1].add('start'); frame.stop = newStop; } - } else if (!(next && newStop.isAfter(next.start))) { + } else if (!modes[mode].checkAdjacent || !(next && newStop.isAfter(next.start))) { frame.stop = newStop; } } @@ -228,10 +230,6 @@ let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeF let timekeeper = new Timekeeper(config); const listSize = program.opts().listsize; -function toHumanReadable(input) { - return Time.toHumanReadable(Math.ceil(input), config.get('hoursPerDay'), timeFormat); -} - function getMenuFrames() { return timekeeper.all().then(({ frames }) => { if (id) { @@ -272,43 +270,55 @@ function getMenuFrames() { }); } -function showNonInteractiveMenu(frames) { - let lastFramesDetails = frames.map((frame) => ({ - name: ` ${formatFrameRow(frame)}`, - value: frame.id, - })); +function showNonInteractiveMenu() { + function prompt(lastFrameDefault) { + return getMenuFrames().then((frames) => { + let lastFramesDetails = frames.map((frame) => ({ + name: ` ${formatFrameRow(frame)}`, + value: frame.id, + })); + + if (lastFramesDetails.length == 0) { + Cli.error("No records found."); + return; + } + if(!lastFrameDefault || !lastFramesDetails.some(frame=>frame.value == lastFrameDefault)) { + lastFrameDefault = lastFramesDetails[lastFramesDetails.length - 1].value; + } + lastFramesDetails.push(new Separator()); + let exit = {name: "Exit", value: null}; + lastFramesDetails.push(exit); - if (lastFramesDetails.length == 0) { - Cli.error("No records found."); - return; + return select({ + message: "Frame?", + default: lastFrameDefault, + choices: lastFramesDetails, + pageSize: lastFramesDetails.length, + }).then((answer) => { + if (!answer) { + return; + } + if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { + Cli.error("record not found."); + } else { + Fs.open(Fs.join(config.frameDir, answer + ".json")); + } + return prompt(answer); + }); + }); } - select({ - message: "Frame?", - default: lastFramesDetails[lastFramesDetails.length - 1].value, - choices: lastFramesDetails, - pageSize: listSize, - }).then((answers) => { - for (const answer of answers) { - if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { - Cli.error("record not found."); - } else { - Fs.open(Fs.join(config.frameDir, answer + ".json")); - } - } - }); + return prompt(null); } if (!id || program.opts().following) { - getMenuFrames() - .then((frames) => { - if (program.opts().interactive) { - return showInteractiveMenu(frames); - } else { - return showNonInteractiveMenu(frames); - } - }) - .catch((error) => Cli.error(error)); + if (program.opts().interactive) { + getMenuFrames() + .then((frames) => showInteractiveMenu(frames)) + .catch((error) => Cli.error(error)); + } else { + showNonInteractiveMenu().catch((error) => Cli.error(error)); + } } else { if (!Fs.exists(Fs.join(config.frameDir, id + ".json"))) Cli.error("No record found."); From 38f409746761c6675bc71ea98ade70e134929936 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 00:14:24 +0200 Subject: [PATCH 51/72] refactor and rewrite table report to use new data --- documentation.md | 6 ++++- spec/output/base.spec.js | 29 ++++++++++++--------- src/core/config.js | 2 +- src/core/task.js | 21 ++++++++------- src/core/time.js | 2 ++ src/reporting/api/dayReport.js | 7 ++++- src/reporting/api/report.js | 7 +++++ src/reporting/output/table.js | 47 +++++++++++++++++++--------------- src/reporting/stats.js | 26 +++++++++++++------ 9 files changed, 92 insertions(+), 55 deletions(-) diff --git a/documentation.md b/documentation.md index 912720f..856a5f1 100644 --- a/documentation.md +++ b/documentation.md @@ -570,12 +570,16 @@ mergeRequestColumns: # Include the given columns in the time record table # See --record_columns option for more information -# defaults to user, date, type, iid, time +# defaults to user, date, project, type, iid, title, time, note recordColumns: - user +- date +- project +- type - iid - title - time +- note # Add columns for each project member to issue and # merge request table, including their total time spent diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js index e9dc238..6f562f6 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -4,18 +4,19 @@ 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 makeTime({ user = 'alice', seconds = 0, date = '2026-01-05T10:00:00Z', iid = 1, project = 'group/project', note = null, chargeRatio = 1.0 } = {}) { + return { user, seconds, iid, project_namespace: project, date: dayjs(date), note, chargeRatio }; } -function makeIssue({ iid = 1, labels = [], times = [], days = {}, estimate = 0, spent = 0 } = {}) { +function makeIssue({ iid = 1, labels = [], times = [], estimate = 0, spent = 0, project_id = 1, title = 'issue' } = {}) { return { iid, + project_id, + title, labels, times, - days, - total_spent : spent, - total_estimate : estimate + total_spent_s : spent, + total_estimate_s : estimate }; } @@ -105,15 +106,19 @@ describe('calculateStats', () => { }); it('consolidates per-day data of all issues', () => { + config.set('dateFormatGroupReport', 'YYYY-MM-DD'); + const output = calculate([ - makeIssue({ iid: 1, days: { '2026-01-01': 'a' } }), - makeIssue({ iid: 2, days: { '2026-01-01': 'b', '2026-01-02': 'c' } }) + makeIssue({ iid: 1, times: [makeTime({ date: '2026-01-01T10:00:00Z', note: 'a', seconds: 60 })] }), + makeIssue({ iid: 2, times: [ + makeTime({ date: '2026-01-01T10:00:00Z', note: 'b', seconds: 120 }), + makeTime({ date: '2026-01-02T10:00:00Z', note: 'c', seconds: 180 }) + ] }) ]); - expect(output.daysNew).to.deep.equal({ - '2026-01-01': ['a', 'b'], - '2026-01-02': ['c'] - }); + expect(Object.keys(output.daysNew)).to.deep.equal(['2026-01-01', '2026-01-02']); + expect(output.daysNew['2026-01-01'].map(d => d.getNotes())).to.deep.equal([['a'], ['b']]); + expect(output.daysNew['2026-01-02'].map(d => d.getNotes())).to.deep.equal([['c']]); }); }); diff --git a/src/core/config.js b/src/core/config.js index c53a460..dcd8e88 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -19,7 +19,7 @@ const defaults = { weeksPerMonth: 4, issueColumns: ['iid', 'title', 'spent', 'total_estimate'], mergeRequestColumns: ['iid', 'title', 'spent', 'total_estimate'], - recordColumns: ['user', 'date', 'type', 'iid', 'time'], + recordColumns: ['user', 'date', 'project', 'type', 'iid', 'title', 'time', 'note'], userColumns: false, dateFormat: 'DD.MM.YYYY HH:mm:ss', timeFormat: Time.defaultTimeFormat, diff --git a/src/core/task.js b/src/core/task.js index 8451890..03e6434 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,14 +1,12 @@ import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; import Time from './time.js'; -import DayReport from '../reporting/api/dayReport.js'; class Task { constructor(config, data = {}, client = new GitlabClient(config), type) { this.config = config; this.client = client; this.times = []; - this.days = {}; this.data = data; this.type = type; } @@ -82,10 +80,18 @@ class Task { return this.data.time_stats ? this.config.toHumanReadable(this.data.time_stats.total_time_spent, this._type) : null; } + get total_spent_s() { + return this.data.time_stats ? this.data.time_stats.total_time_spent : 0; + } + get total_estimate() { return this.data.time_stats ? this.config.toHumanReadable(this.data.time_stats.time_estimate, this._type) : null; } + get total_estimate_s() { + return this.data.time_stats ? this.data.time_stats.time_estimate : 0; + } + get _type() { return this.type; } @@ -183,15 +189,6 @@ class Task { timelogs.forEach( (timelog) => { let spentAt = dayjs(timelog.spentAt); - let dateGrp = spentAt.format(this.config.get('dateFormatGroupReport')); - if(!this.days[dateGrp]) - { - this.days[dateGrp] = new DayReport(this.iid, this.title, spentAt, chargeRatio); - } - if(timelog.note && timelog.note.body) { - this.days[dateGrp].addNote(timelog.note.body); - } - this.days[dateGrp].addSpent(timelog.timeSpent); let time = new Time(null, spentAt, { author: {username: timelog.user.username}, @@ -200,6 +197,8 @@ class Task { }, this, this.config); time.seconds = timelog.timeSpent; time.project_namespace = this.project_namespace; + time.note = timelog.note && timelog.note.body ? timelog.note.body : null; + time.chargeRatio = chargeRatio; // only include times by the configured user if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; diff --git a/src/core/time.js b/src/core/time.js index 0648a7c..fc71f1b 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -28,6 +28,8 @@ class Time { this._date = date; this.parent = parent; this.config = config; + this.note = null; + this.chargeRatio = 1.0; if(!timeString) { return; diff --git a/src/reporting/api/dayReport.js b/src/reporting/api/dayReport.js index 6d8f71b..658a85d 100644 --- a/src/reporting/api/dayReport.js +++ b/src/reporting/api/dayReport.js @@ -3,7 +3,8 @@ * day model of one item */ class DayReport { - constructor(iid, title, spentAt, chargeRatio) { + constructor(project_id, iid, title, spentAt, chargeRatio) { + this.project_id = project_id; this.iid = iid; this.title = title; this.spentAt = spentAt; @@ -12,6 +13,10 @@ class DayReport { this.spent = 0; this.notes = []; } + + getProjectId() { + return this.project_id; + } getIid() { return this.iid; diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 264eac3..18f41aa 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -139,6 +139,7 @@ class Report { endCursor } nodes { + id user { username } @@ -152,10 +153,16 @@ class Report { mergeRequests:mergeRequest { iid projectId + title } issues:issue { iid projectId + title + } + project { + id + name } } } diff --git a/src/reporting/output/table.js b/src/reporting/output/table.js index 0bab106..6d24e1b 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -1,6 +1,7 @@ import Table from 'cli-table'; import Output from './base.js'; import pc from 'picocolors'; +import dayjs from 'dayjs'; const format = { headline: h => `\n${pc.bold(pc.underline(h))}\n`, @@ -58,29 +59,21 @@ class TableOutput extends Output { makeDailyStats() { this.headline('DAILY RECORDS'); - var tabledt = new Table({head: ['date', 'time']}); var tabledit = new Table({head: ['date', 'project', 'iid', 'time']}); - let days = Object.keys(this.days); - days.sort(); - days.forEach( - k => { - let day = this.days[k]; - let refD = this.daysMoment[k].format(this.config.get('dateFormat')); - let projects = Object.keys(day); - let time = 0; - projects.forEach( - p => { - let iids = Object.keys(day[p]); - iids.sort(); - iids.forEach( - iid => { - tabledit.push([refD, p, iid, this.config.toHumanReadable(day[p][iid], 'records')]); - time += day[p][iid]; - }); - }); - tabledt.push([refD, this.config.toHumanReadable(time)]); + let daysNew = Object.keys(this.daysNew); + daysNew.sort(); + daysNew.forEach(k => { + let dayReports = this.daysNew[k]; + // for each day + let refD = this.daysMoment[k].format(this.config.get('dateFormat')); + let time = 0; + dayReports.forEach(dayReport => { + tabledit.push([refD, this.report.projects[dayReport.getProjectId()], dayReport.getIid(), this.config.toHumanReadable(dayReport.getSpent(), 'records')]); + time += dayReport.getSpent(); }); + tabledt.push([refD, this.config.toHumanReadable(time)]); + }); this.write(tabledt.toString()); this.write(tabledit.toString()); } @@ -89,7 +82,19 @@ class TableOutput extends Output { this.makeDailyStats(); this.headline('TIME RECORDS'); let times = new Table({head: this.config.get('recordColumns').map(c => c.replace('_', ' '))}); - this.times.forEach(time => times.push(this.prepare(time, this.config.get('recordColumns')))); + this.report.timelogs.forEach(timelog => + { + let data = { + ...timelog.issues, + ...timelog.mergeRequests, + time: this.config.toHumanReadable(timelog.timeSpent, 'records'), + date: dayjs(timelog.spentAt), + type: timelog.mergeRequests ? 'Merge Request' : 'Issue', + project: timelog.project.name, + user: timelog.user.username, + note: timelog.note?.body ?? ''}; + times.push(this.prepare(data, this.config.get('recordColumns'))); + }); this.write(times.toString()); } } diff --git a/src/reporting/stats.js b/src/reporting/stats.js index 30a8401..6ae5756 100644 --- a/src/reporting/stats.js +++ b/src/reporting/stats.js @@ -1,3 +1,5 @@ +import DayReport from './api/dayReport.js'; + /** * Aggregate a merged report into the numbers the output formats render: * per-user/per-project/per-day spent time, estimate and spent totals, @@ -33,15 +35,23 @@ export default function calculateStats(config, report) { 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]); - }); + let issueDays = {}; issue.times.forEach(time => { let dateGrp = time.date.format(config.get('dateFormatGroupReport')); + + if (!issueDays[dateGrp]) { + issueDays[dateGrp] = new DayReport(issue.project_id, issue.iid, issue.title, time.date, time.chargeRatio); + if (!daysNew[dateGrp]) { + daysNew[dateGrp] = []; + } + daysNew[dateGrp].push(issueDays[dateGrp]); + } + if (time.note) { + issueDays[dateGrp].addNote(time.note); + } + issueDays[dateGrp].addSpent(time.seconds); + if (!users[time.user]) users[time.user] = 0; if (!projects[time.project_namespace]) projects[time.project_namespace] = 0; if (!days[dateGrp]) { @@ -69,8 +79,8 @@ export default function calculateStats(config, report) { } times.push(time); }); - totalEstimate += parseFloat(issue.total_estimate); - totalSpent += parseFloat(issue.total_spent); + totalEstimate += parseFloat(issue.total_estimate_s); + totalSpent += parseFloat(issue.total_spent_s); }); report[type].sort((a, b) => { From 35ba779c96584ad1e15c42fa7e39abab761fff97 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 16:37:57 +0200 Subject: [PATCH 52/72] 1.9.0-beta0001 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b32ee2..688cf73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-time-tracker", - "version": "1.9.0-snapshot", + "version": "1.9.0-beta0001", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-time-tracker", - "version": "1.9.0-snapshot", + "version": "1.9.0-beta0001", "license": "GPL-2.0", "dependencies": { "@inquirer/confirm": "^6.1.1", diff --git a/package.json b/package.json index 88145f3..01d2b6e 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitlab-time-tracker", - "version": "1.9.0-snapshot", + "version": "1.9.0-beta0001", "type": "module", "description": "A command line interface for GitLabs time tracking feature.", "bugs": { diff --git a/src/version.js b/src/version.js index 797ff63..71a66bc 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -let version = '1.9.0-snapshot'; export default version; +let version = '1.9.0-beta0001'; export default version; From 1f72ef6829e1b3d9a2d63413f559ee2511befc25 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:03:09 +0200 Subject: [PATCH 53/72] refactor report (builder/runner) --- spec/reporting/reportConfigBuilder.spec.js | 112 +++++++++ src/reporting/commands/report.js | 271 +-------------------- src/reporting/reportConfigBuilder.js | 121 +++++++++ src/reporting/reportRunner.js | 184 ++++++++++++++ 4 files changed, 425 insertions(+), 263 deletions(-) create mode 100644 spec/reporting/reportConfigBuilder.spec.js create mode 100644 src/reporting/reportConfigBuilder.js create mode 100644 src/reporting/reportRunner.js diff --git a/spec/reporting/reportConfigBuilder.spec.js b/spec/reporting/reportConfigBuilder.spec.js new file mode 100644 index 0000000..23bfe6a --- /dev/null +++ b/spec/reporting/reportConfigBuilder.spec.js @@ -0,0 +1,112 @@ +import { expect } from 'chai'; +import Config from '../../src/core/config.js'; +import Args from '../../src/core/args.js'; +import { buildReportConfig, validateReportConfig } from '../../src/reporting/reportConfigBuilder.js'; + +const Output = { table: () => {}, csv: () => {} }; + +function opts(overrides = {}) { + return Object.assign({ + url: undefined, token: undefined, from: undefined, to: undefined, + closed: undefined, user: undefined, milestone: undefined, + include_by_labels: undefined, exclude_by_labels: undefined, + include_labels: undefined, exclude_labels: undefined, + date_format: undefined, time_format: undefined, hours_per_day: undefined, + output: 'table', file: undefined, query: undefined, report: undefined, + record_columns: undefined, issue_columns: undefined, merge_request_columns: undefined, + no_headlines: undefined, no_warnings: undefined, quiet: undefined, + show_without_times: undefined, user_columns: undefined, type: undefined, + subgroups: undefined, verbose: undefined, + invoiceTitle: undefined, invoiceReference: undefined, invoiceText: undefined, + invoiceAddress: undefined, invoiceCurrency: undefined, invoiceCurrencyPerHour: undefined, + invoiceVAT: undefined, invoiceDate: undefined, invoiceTimeMaxUnit: undefined, + invoiceCurrencyMaxUnit: undefined, invoicePositionText: undefined, + invoicePositionExtra: undefined, invoicePositionExtraText: undefined, + invoicePositionExtraValue: undefined + }, overrides); +} + +describe('buildReportConfig', () => { + let config; + + beforeEach(() => { + config = new Config(); + }); + + it('maps plain options onto config', () => { + buildReportConfig(config, opts({ url: 'https://example.com', token: 'abc', output: 'csv' }), new Args([])); + + expect(config.get('url')).to.equal('https://example.com'); + expect(config.get('token')).to.equal('abc'); + expect(config.get('output')).to.equal('csv'); + }); + + it('resolves the project and iids from positional args', () => { + buildReportConfig(config, opts(), new Args(['group/project', '12', '34'])); + + expect(config.get('project')).to.deep.equal(['group/project']); + expect(config.get('iids')).to.deep.equal(['12', '34']); + }); + + it('applies the --today shortcut over --from/--to', () => { + buildReportConfig(config, opts({ today: true, from: '2020-01-01', to: '2020-01-02' }), new Args([])); + + expect(config.get('from').format('YYYY-MM-DD')).to.equal(config.get('to').subtract(1, 'day').format('YYYY-MM-DD')); + }); + + it('splits invoicePositionExtraText/Value into arrays, defaulting to a single empty entry', () => { + buildReportConfig(config, opts(), new Args([])); + + expect(config.get('invoicePositionExtraText')).to.deep.equal(['']); + expect(config.get('invoicePositionExtraValue')).to.deep.equal(['']); + }); +}); + +describe('validateReportConfig', () => { + let config; + + beforeEach(() => { + config = new Config(); + }); + + it('errors when no project is set', () => { + buildReportConfig(config, opts(), new Args([])); + + const { errors } = validateReportConfig(config, Output); + + expect(errors).to.include('Missing project(s) or group(s) namespace. Try this: gtt report "username/project-name"'); + }); + + it('errors when the output backend does not exist', () => { + buildReportConfig(config, opts({ output: 'nope' }), new Args(['group/project'])); + + const { errors } = validateReportConfig(config, Output); + + expect(errors.some(e => e.includes('nope'))).to.be.true; + }); + + it('errors when invoicePositionExtraText/Value lengths do not match', () => { + buildReportConfig(config, opts({ invoicePositionExtraText: 'a,b', invoicePositionExtraValue: '1' }), new Args(['group/project'])); + + const { errors } = validateReportConfig(config, Output); + + expect(errors).to.include('invoicePositionExtraText and invoicePositionExtraValue length do not match'); + }); + + it('warns when ids are given but multiple query types are requested', () => { + buildReportConfig(config, opts({ query: undefined }), new Args(['group/project', '5'])); + + const { warnings } = validateReportConfig(config, Output); + + expect(warnings).to.include('The ids argument is ignored when querying issues and merge requests'); + }); + + it('passes cleanly for a well-formed project report', () => { + buildReportConfig(config, opts(), new Args(['group/project'])); + + const { errors, warnings } = validateReportConfig(config, Output); + + expect(errors).to.deep.equal([]); + expect(warnings).to.deep.equal([]); + }); +}); diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index 9c97dca..a4d0003 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -1,22 +1,10 @@ import fs from 'fs'; -import pc from 'picocolors'; import {Command} from 'commander'; -import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; import Args from '../../core/args.js'; -import Report from '../api/report.js'; -import Owner from '../../core/owner.js'; -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 (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') -}; +import {buildReportConfig, validateReportConfig} from '../reportConfigBuilder.js'; +import {runReport, Output} from '../reportRunner.js'; // this collects options function collect(val, arr) { @@ -79,117 +67,14 @@ function report(configLoader) { .option('--invoicePositionExtraValue ', 'extra invoice position: value') .action(async (project, ids, options, program) => { -// init helpers -let config = configLoader(); -let args = new Args(program.args); - -// overwrite config with args and opts -config - .set('url', program.opts().url) - .set('token', program.opts().token) - .set('project', args.project()) - .set('iids', args.iids()) - .set('from', program.opts().from) - .set('to', program.opts().to) - .set('closed', program.opts().closed) - .set('user', program.opts().user) - .set('milestone', program.opts().milestone) - .set('includeByLabels', program.opts().include_by_labels) - .set('excludeByLabels', program.opts().exclude_by_labels) - .set('includeLabels', program.opts().include_labels) - .set('excludeLabels', program.opts().exclude_labels) - .set('dateFormat', program.opts().date_format) - .set('timeFormat', program.opts().time_format) - .set('hoursPerDay', program.opts().hours_per_day) - .set('output', program.opts().output) - .set('file', program.opts().file) - .set('query', program.opts().query) - .set('report', program.opts().report) - .set('recordColumns', program.opts().record_columns) - .set('issueColumns', program.opts().issue_columns) - .set('mergeRequestColumns', program.opts().merge_request_columns) - .set('noHeadlines', program.opts().no_headlines) - .set('noWarnings', program.opts().no_warnings) - .set('quiet', program.opts().quiet) - .set('showWithoutTimes', program.opts().show_without_times) - .set('userColumns', program.opts().user_columns) - .set('type', program.opts().type) - .set('subgroups', program.opts().subgroups) - .set('_verbose', program.opts().verbose) - .set('invoiceTitle', program.opts().invoiceTitle) - .set('invoiceReference', program.opts().invoiceReference) - .set('invoiceText', program.opts().invoiceText) - .set('invoiceAddress', program.opts().invoiceAddress) - .set('invoiceCurrency', program.opts().invoiceCurrency) - .set('invoiceCurrencyPerHour', program.opts().invoiceCurrencyPerHour) - .set('invoiceVAT', program.opts().invoiceVAT) - .set('invoiceDate', program.opts().invoiceDate) - .set('invoiceTimeMaxUnit', program.opts().invoiceTimeMaxUnit) - .set('invoiceCurrencyMaxUnit', program.opts().invoiceCurrencyMaxUnit) - .set('invoicePositionText', program.opts().invoicePositionText) - .set('invoicePositionExtra', program.opts().invoicePositionExtra) - .set('invoicePositionExtraText', (program.opts().invoicePositionExtraText? program.opts().invoicePositionExtraText: "").split(',')) - .set('invoicePositionExtraValue', (program.opts().invoicePositionExtraValue? program.opts().invoicePositionExtraValue: "").split(',')); - -// date shortcuts -if (program.opts().today) - config - .set('from', dayjs().startOf('day')) - .set('to', dayjs().add(1, 'day').startOf('day')); -if (program.opts().this_week) - config - .set('from', dayjs().startOf('week')) - .set('to', dayjs().endOf('week').add(1, 'day').startOf('day')); -if (program.opts().this_month) - config - .set('from', dayjs().startOf('month')) - .set('to', dayjs().endOf('month').add(1, 'day').startOf('day')); -if (program.opts().last_month) - config - .set('from', dayjs().subtract(1, 'months').startOf('month')) - .set('to', dayjs().subtract(1, 'months').endOf('month').add(1, 'day').startOf('day')); +let config = buildReportConfig(configLoader(), program.opts(), new Args(program.args)); Cli.quiet = config.get('quiet'); Cli.verbose = config.get('_verbose'); -// check extra Text/value arrays -if(config.get('invoicePositionExtraText').length != config.get('invoicePositionExtraValue').length) { - Cli.error(`invoicePositionExtraText and invoicePositionExtraValue length do not match`); -} - -// create stuff -let client = new GitlabClient(config), - reports = new ReportCollection(config), - master = new Report(config, undefined, client), - 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 -if (config.get('iids').length >= 1 && config.get('query').length > 1) { - Cli.warn(`The ids argument is ignored when querying issues and merge requests`); -} -if (config.get('iids').length >= 1 && (config.get('type') !== 'project' || projects.length > 1)) { - Cli.warn(`The ids argument is ignored when querying multiple projects`); -} -if ((config.get('report').includes('issues') && !config.get('query').includes('issues'))) { - Cli.warn(`Issues are included in the report but not queried.`); -} -if ((config.get('report').includes('merge_requests') && !config.get('query').includes('merge_requests'))) { - Cli.warn(`Merge Requests are included in the report but not queried.`); -} -if (!config.get('project')) { - Cli.error(`Missing project(s) or group(s) namespace. Try this: gtt report "username/project-name"`); -} -if (!Output[config.get('output')]) { - Cli.error(`The output ${config.get('output')} doesn't exist. Available outputs: ${Object.keys(Output).join(',')}`); -} -if (!config.get('from').isValid()) { - Cli.error(`FROM is not in a valid ISO date format.`); -} -if (!config.get('to').isValid()) { - Cli.error(`TO is not a in valid ISO date format.`); -} +let {errors, warnings} = validateReportConfig(config, Output); +warnings.forEach(warning => Cli.warn(warning)); +if (errors.length > 0) Cli.error(errors[0]); // file prompt if (config.get('file') && fs.existsSync(config.get('file'))) { @@ -200,148 +85,8 @@ if (config.get('file') && fs.existsSync(config.get('file'))) { } } -// get project(s) -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); - } - 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 - 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); -} - -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(); +let client = new GitlabClient(config); +let output = await runReport(config, client, Cli); // print report Cli.list(`${Cli.print} Printing report`); diff --git a/src/reporting/reportConfigBuilder.js b/src/reporting/reportConfigBuilder.js new file mode 100644 index 0000000..257e2b7 --- /dev/null +++ b/src/reporting/reportConfigBuilder.js @@ -0,0 +1,121 @@ +import dayjs from '../core/dayjs.js'; + +/** + * Apply report command options and positional args onto config, including + * the --today/--this_week/--this_month/--last_month date shortcuts. + * @param config + * @param opts commander's program.opts() + * @param args Args instance built from program.args + * @returns {Config} + */ +export function buildReportConfig(config, opts, args) { + config + .set('url', opts.url) + .set('token', opts.token) + .set('project', args.project()) + .set('iids', args.iids()) + .set('from', opts.from) + .set('to', opts.to) + .set('closed', opts.closed) + .set('user', opts.user) + .set('milestone', opts.milestone) + .set('includeByLabels', opts.include_by_labels) + .set('excludeByLabels', opts.exclude_by_labels) + .set('includeLabels', opts.include_labels) + .set('excludeLabels', opts.exclude_labels) + .set('dateFormat', opts.date_format) + .set('timeFormat', opts.time_format) + .set('hoursPerDay', opts.hours_per_day) + .set('output', opts.output) + .set('file', opts.file) + .set('query', opts.query) + .set('report', opts.report) + .set('recordColumns', opts.record_columns) + .set('issueColumns', opts.issue_columns) + .set('mergeRequestColumns', opts.merge_request_columns) + .set('noHeadlines', opts.no_headlines) + .set('noWarnings', opts.no_warnings) + .set('quiet', opts.quiet) + .set('showWithoutTimes', opts.show_without_times) + .set('userColumns', opts.user_columns) + .set('type', opts.type) + .set('subgroups', opts.subgroups) + .set('_verbose', opts.verbose) + .set('invoiceTitle', opts.invoiceTitle) + .set('invoiceReference', opts.invoiceReference) + .set('invoiceText', opts.invoiceText) + .set('invoiceAddress', opts.invoiceAddress) + .set('invoiceCurrency', opts.invoiceCurrency) + .set('invoiceCurrencyPerHour', opts.invoiceCurrencyPerHour) + .set('invoiceVAT', opts.invoiceVAT) + .set('invoiceDate', opts.invoiceDate) + .set('invoiceTimeMaxUnit', opts.invoiceTimeMaxUnit) + .set('invoiceCurrencyMaxUnit', opts.invoiceCurrencyMaxUnit) + .set('invoicePositionText', opts.invoicePositionText) + .set('invoicePositionExtra', opts.invoicePositionExtra) + .set('invoicePositionExtraText', (opts.invoicePositionExtraText ? opts.invoicePositionExtraText : '').split(',')) + .set('invoicePositionExtraValue', (opts.invoicePositionExtraValue ? opts.invoicePositionExtraValue : '').split(',')); + + if (opts.today) + config + .set('from', dayjs().startOf('day')) + .set('to', dayjs().add(1, 'day').startOf('day')); + if (opts.this_week) + config + .set('from', dayjs().startOf('week')) + .set('to', dayjs().endOf('week').add(1, 'day').startOf('day')); + if (opts.this_month) + config + .set('from', dayjs().startOf('month')) + .set('to', dayjs().endOf('month').add(1, 'day').startOf('day')); + if (opts.last_month) + config + .set('from', dayjs().subtract(1, 'months').startOf('month')) + .set('to', dayjs().subtract(1, 'months').endOf('month').add(1, 'day').startOf('day')); + + return config; +} + +/** + * Validate a report config, built by buildReportConfig, against the + * available output backends. Pure - returns messages instead of printing + * or exiting so callers decide how to surface them. + * @param config + * @param Output map of output name -> loader, e.g. {table: () => import(...)} + * @returns {{errors: string[], warnings: string[]}} + */ +export function validateReportConfig(config, Output) { + let errors = []; + let warnings = []; + let projects = Array.isArray(config.get('project')) ? config.get('project') : [config.get('project')]; + + if (config.get('invoicePositionExtraText').length != config.get('invoicePositionExtraValue').length) { + errors.push(`invoicePositionExtraText and invoicePositionExtraValue length do not match`); + } + if (config.get('iids').length >= 1 && config.get('query').length > 1) { + warnings.push(`The ids argument is ignored when querying issues and merge requests`); + } + if (config.get('iids').length >= 1 && (config.get('type') !== 'project' || projects.length > 1)) { + warnings.push(`The ids argument is ignored when querying multiple projects`); + } + if (config.get('report').includes('issues') && !config.get('query').includes('issues')) { + warnings.push(`Issues are included in the report but not queried.`); + } + if (config.get('report').includes('merge_requests') && !config.get('query').includes('merge_requests')) { + warnings.push(`Merge Requests are included in the report but not queried.`); + } + if (!config.get('project')) { + errors.push(`Missing project(s) or group(s) namespace. Try this: gtt report "username/project-name"`); + } + if (!Output[config.get('output')]) { + errors.push(`The output ${config.get('output')} doesn't exist. Available outputs: ${Object.keys(Output).join(',')}`); + } + if (!config.get('from').isValid()) { + errors.push(`FROM is not in a valid ISO date format.`); + } + if (!config.get('to').isValid()) { + errors.push(`TO is not a in valid ISO date format.`); + } + + return { errors, warnings }; +} diff --git a/src/reporting/reportRunner.js b/src/reporting/reportRunner.js new file mode 100644 index 0000000..90cfda7 --- /dev/null +++ b/src/reporting/reportRunner.js @@ -0,0 +1,184 @@ +import pc from 'picocolors'; +import Cli from '../core/cli.js'; +import Owner from '../core/owner.js'; +import Report from './api/report.js'; +import ReportCollection from './api/reportCollection.js'; +import parallel from '../core/parallel.js'; + +// output backends are imported lazily so timekeeping commands never pull in +// the heavy deps (swissqrbill, markdown-table, csv-string, cli-table) +export const Output = { + table: () => import('./output/table.js'), + csv: () => import('./output/csv.js'), + markdown: () => import('./output/markdown.js'), + invoice: () => import('./output/invoice.js') +}; + +async function resolveProjects(config, client, owner, reports, reporter) { + let projectLabels = Array.isArray(config.get('project')) ? config.get('project').join('", "') : config.get('project'); + let projects = Array.isArray(config.get('project')) ? config.get('project') : [config.get('project')]; + + reporter.list(`${reporter.look} Resolving "${projectLabels}"`); + + try { + await owner.authorized(); + } catch (error) { + reporter.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) { + reporter.x(`Project not found or no access rights "${projectLabels}".`, error); + } + 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); + reporter.out(`\r${reporter.look} Selected projects: ${reports.reports.map(r => pc.bold(pc.blue(r.project.name))).join(', ')}\n`); + + 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))]); + }); + } + + reporter.mark(); + } catch (error) { + reporter.x(`Could not resolve "${projectLabels}"`, error); + } +} + +async function fetchIssues(config, reports, reporter) { + if (!config.get('query').includes('issues')) return; + + reporter.list(`${reporter.fetch} Fetching issues`); + try { + await reports.forEach(report => report.getIssues()); + } catch (error) { + reporter.x(`could not fetch issues.`, error); + } + reporter.mark(); +} + +async function fetchMergeRequests(config, reports, reporter) { + if (!config.get('query').includes('merge_requests')) return; + + reporter.list(`${reporter.fetch} Fetching merge requests`); + try { + await reports.forEach(report => report.getMergeRequests()); + } catch (error) { + reporter.x(`could not fetch merge requests.`, error); + } + reporter.mark(); +} + +async function fetchTimelogs(reports, reporter) { + reporter.list(`${reporter.fetch} Loading timelogs`); + try { + await reports.forEach(report => report.getTimelogs()); + } catch (error) { + reporter.x(`could not load timelogs.`, error); + } + reporter.mark(); +} + +async function mergeReports(reports, master, reporter) { + reporter.list(`${reporter.merge} Merging reports`); + try { + await reports.forEach(report => master.merge(report)); + } catch (error) { + reporter.x(`could not merge reports.`, error); + } + reporter.mark(); +} + +async function processIssues(config, master, reporter) { + if (!(config.get('query').includes('issues') && master.issues.length > 0)) return; + + reporter.bar(`${reporter.process}️ Processing issues`, master.issues.length); + try { + await master.processIssues(() => reporter.advance()); + } catch (error) { + reporter.x(`could not process issues.`, error); + } + reporter.mark(); +} + +async function processMergeRequests(config, master, reporter) { + if (!(config.get('query').includes('merge_requests') && master.mergeRequests.length > 0)) return; + + reporter.bar(`${reporter.process}️️ Processing merge requests`, master.mergeRequests.length); + try { + await master.processMergeRequests(() => reporter.advance()); + } catch (error) { + reporter.x(`could not process merge requests.`, error); + } + reporter.mark(); +} + +async function makeOutput(config, master, reporter) { + if (master.issues.length === 0 && master.mergeRequests.length === 0) { + reporter.error('No issues or merge requests matched your criteria.'); + } + + let output; + try { + let module = await Output[config.get('output')](); + + reporter.list(`${reporter.output} Making report`); + output = new module.default(config, master); + output.make(); + } catch (error) { + reporter.x(`could not make report.`, error); + } + reporter.mark(); + + return output; +} + +/** + * Resolve projects/groups, fetch issues/merge requests/timelogs, merge + * and process them, and render the configured output. One interface for + * the whole report pipeline - testable with a fake client and reporter, + * without driving commander or process.stdout. + * @param config a config built by buildReportConfig + * @param client GitlabClient + * @param reporter progress/error reporter, shaped like Cli (list/mark/x/bar/advance/out/error + emoji getters) + * @returns {Promise} the rendered output, ready for toFile/toStdOut + */ +export async function runReport(config, client, reporter = Cli) { + let owner = new Owner(config, client), + reports = new ReportCollection(config), + master = new Report(config, undefined, client); + + await resolveProjects(config, client, owner, reports, reporter); + await fetchIssues(config, reports, reporter); + await fetchMergeRequests(config, reports, reporter); + await fetchTimelogs(reports, reporter); + await mergeReports(reports, master, reporter); + await processIssues(config, master, reporter); + await processMergeRequests(config, master, reporter); + + return makeOutput(config, master, reporter); +} From 0644f80e01dfd960cf7472b5c17332ef0cc415e0 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:05:16 +0200 Subject: [PATCH 54/72] refactor sync --- spec/timekeeping/timekeeper.spec.js | 124 ++++++++++++++++++++++++++++ src/timekeeping/commands/sync.js | 47 ++++++----- src/timekeeping/timekeeper.js | 88 ++++++++++---------- 3 files changed, 196 insertions(+), 63 deletions(-) create mode 100644 spec/timekeeping/timekeeper.spec.js diff --git a/spec/timekeeping/timekeeper.spec.js b/spec/timekeeping/timekeeper.spec.js new file mode 100644 index 0000000..49c2ed7 --- /dev/null +++ b/spec/timekeeping/timekeeper.spec.js @@ -0,0 +1,124 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import Config from '../../src/core/config.js'; +import Task from '../../src/core/task.js'; +import Timekeeper from '../../src/timekeeping/timekeeper.js'; + +function fakeFrames(frames) { + return { + length: frames.length, + forEach: (iterator) => Promise.all(frames.map(frame => iterator(frame))) + }; +} + +function makeFrame({ project = 'group/project', type = 'issue', id = 1, duration = 3600, notes = [], isNew = false } = {}) { + return { + project, + resource: { type, id, ...(isNew ? { new: true } : {}) }, + duration, + notes, + note: null, + _stop: '2026-01-01T00:00:00Z', + title: null, + write: sinon.spy() + }; +} + +describe('Timekeeper', () => { + let config, timekeeper, make, createTime, getNotes; + + beforeEach(() => { + config = new Config(); + timekeeper = new Timekeeper(config); + + make = sinon.stub(Task.prototype, 'make').callsFake(function (project, id) { + this.data = { title: `title for ${id}`, iid: 99 }; + return Promise.resolve(this); + }); + createTime = sinon.stub(Task.prototype, 'createTime').resolves({ id: 'gid://gitlab/Note/555' }); + getNotes = sinon.stub(Task.prototype, 'getNotes').resolves([]); + }); + + afterEach(() => { + make.restore(); + createTime.restore(); + getNotes.restore(); + }); + + describe('pendingFrames', () => { + it('is exposed as a public interface used by both sync and archiveInit', () => { + expect(timekeeper.pendingFrames).to.be.a('function'); + }); + }); + + describe('sync', () => { + it('returns immediately without touching phases when there is nothing to sync', async () => { + const onPhase = sinon.spy(); + + await timekeeper.sync(fakeFrames([]), { onPhase }); + + expect(onPhase.called).to.be.false; + }); + + it('runs resolve, details and update in order with the right totals', async () => { + const frames = [makeFrame({ id: 1 }), makeFrame({ id: 2 })]; + const phases = []; + + await timekeeper.sync(fakeFrames(frames), { + onPhase: (phase, total) => phases.push([phase, total]) + }); + + expect(phases).to.deep.equal([ + ['resolve', 2], ['details', 2], ['update', 2] + ]); + }); + + it('resolves each distinct project/type/id once, and skips onProgress for repeats', async () => { + const frames = [ + makeFrame({ id: 1 }), + makeFrame({ id: 1 }), // same issue tracked across two frames + makeFrame({ id: 2 }) + ]; + let resolveProgressCalls = 0; + let phase = null; + + await timekeeper.sync(fakeFrames(frames), { + onPhase: (name) => { phase = name; }, + onProgress: () => { if (phase === 'resolve') resolveProgressCalls++; } + }); + + expect(make.callCount).to.equal(2); + expect(resolveProgressCalls).to.equal(2); + }); + + it('sets frame.title from the resolved resource', async () => { + const frame = makeFrame({ id: 7 }); + + await timekeeper.sync(fakeFrames([frame])); + + expect(frame.title).to.equal('title for 7'); + }); + + it('writes a time record note for the untracked duration and persists the frame', async () => { + const frame = makeFrame({ duration: 3600, notes: [{ id: 1, time: 1000 }] }); + + await timekeeper.sync(fakeFrames([frame])); + + expect(createTime.calledOnce).to.be.true; + expect(createTime.firstCall.args[0]).to.equal(2600); + expect(frame.notes).to.have.lengthOf(2); + expect(frame.notes[1]).to.deep.equal({ id: 555, time: 2600 }); + expect(frame.write.calledWith(true)).to.be.true; + }); + + it('promotes a new resource id to the created iid', async () => { + const frame = makeFrame({ id: 'new-issue-title', isNew: true }); + + await timekeeper.sync(fakeFrames([frame])); + + expect(frame.resource.new).to.be.undefined; + expect(frame.resource.title).to.equal('new-issue-title'); + expect(frame.resource.id).to.equal(99); + }); + }); +}); diff --git a/src/timekeeping/commands/sync.js b/src/timekeeping/commands/sync.js index 67b38c3..5e9f91c 100755 --- a/src/timekeeping/commands/sync.js +++ b/src/timekeeping/commands/sync.js @@ -8,7 +8,7 @@ function sync(configLoader) { .option('--url ', 'URL to GitLabs API') .option('--token ', 'API access token') .option('--verbose', 'show verbose output') - .action((options, program) => { + .action(async (options, program) => { Cli.verbose = program.opts().verbose; @@ -16,25 +16,32 @@ let config = configLoader() .set('url', program.opts().url) .set('token', program.opts().token); let timekeeper = new Timekeeper(config), -owner = new Owner(config); - -timekeeper.syncInit() - .then(() => timekeeper.sync.frames.length === 0 ? process.exit(0) : null) - .then(() => owner.authorized()) - .catch(e => Cli.x(`Invalid access token!`, e)) - .then(() => { - Cli.bar(`${Cli.fetch} Fetching or creating issues & merge requests...`, timekeeper.sync.frames.length); - return timekeeper.syncResolve(Cli.advance); - }) - .then(() => { - Cli.bar(`${Cli.process} Processing issues & merge requests...`, timekeeper.sync.frames.length); - return timekeeper.syncDetails(Cli.advance); - }) - .then(() => { - Cli.bar(`${Cli.update} Syncing time records...`, timekeeper.sync.frames.length); - return timekeeper.syncUpdate(Cli.advance) - }) - .catch(error => Cli.x(error)); + owner = new Owner(config); + +let frames = await timekeeper.pendingFrames(); +if (frames.length === 0) process.exit(0); + +try { + await owner.authorized(); +} catch (e) { + Cli.x(`Invalid access token!`, e); + return; +} + +const phaseMessages = { + resolve: `${Cli.fetch} Fetching or creating issues & merge requests...`, + details: `${Cli.process} Processing issues & merge requests...`, + update: `${Cli.update} Syncing time records...` +}; + +try { + await timekeeper.sync(frames, { + onPhase: (phase, total) => Cli.bar(phaseMessages[phase], total), + onProgress: () => Cli.advance() + }); +} catch (error) { + Cli.x(error); +} } ); diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 5bb5458..25262d5 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -14,7 +14,6 @@ const CURRENT_FILE = '~current.tmp'; class Timekeeper { constructor(config) { this.config = config; - this.sync = {}; } /** @@ -41,14 +40,16 @@ class Timekeeper { } /** - * Filter frames that need an update - * @returns {Promise} + * Frames that still need a GitLab time record: their tracked duration + * doesn't match what's already been recorded as notes. + * @returns {Promise} */ - async syncInit() { - this.sync.frames = new FrameCollection(this.config); + async pendingFrames() { + let frames = new FrameCollection(this.config); + + frames.filter(frame => !(Math.ceil(frame.duration) === frame.notes.reduce((n, m) => (n + m.time), 0))); - // filter out frames, that don't need an update - this.sync.frames.filter(frame => !(Math.ceil(frame.duration) === frame.notes.reduce((n, m) => (n + m.time), 0))); + return frames; } /** @@ -58,9 +59,9 @@ class Timekeeper { * @returns {Promise} {year: {month: [frame, ...]}} */ async archiveInit() { - await this.syncInit(); + let pending = await this.pendingFrames(); - if (this.sync.frames.length > 0) { + if (pending.length > 0) { throw new Error('Not all frames are synced yet. Run `gtt sync` first.'); } @@ -82,59 +83,58 @@ class Timekeeper { } /** - * Resolve merge_requests and issues - * respectively. - * @returns {Promise} + * Sync the given frames to GitLab: resolve or create their issue/merge + * request, refresh the frame title, then push a time record note for + * whatever duration hasn't been recorded yet. One call, three internal + * phases (resolve/details/update) - callers no longer choreograph them. + * @param {FrameCollection} frames typically the result of pendingFrames() + * @param {Object} [hooks] + * @param {function(phase: 'resolve'|'details'|'update', total: number)} [hooks.onPhase] called once per phase, before it starts + * @param {function()} [hooks.onProgress] called after each frame is resolved/updated + * @returns {Promise} the frames that were synced */ - syncResolve(callback) { - this.sync.resources = {} + async sync(frames, {onPhase = () => {}, onProgress = () => {}} = {}) { + if (frames.length === 0) return frames; - // resolve issues and merge requests - return this.sync.frames.forEach(async frame => { + let resources = {}; + + onPhase('resolve', frames.length); + await frames.forEach(async frame => { let project = frame.project, type = frame.resource.type, id = frame.resource.id; - if(!(project in this.sync.resources)) - { - this.sync.resources[project]= - { - issue: {}, - merge_request: {} - }; + + if (!(project in resources)) { + resources[project] = {issue: {}, merge_request: {}}; } - if(id in this.sync.resources[project][type]) { + if (id in resources[project][type]) { return; } - this.sync.resources[project][type][id] = new classes[type](this.config, {}); + resources[project][type][id] = new classes[type](this.config, {}); try { - await this.sync.resources[project][type][id].make(project, id, frame.resource.new); + await 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(); - }) - } + onProgress(); + }); - /** - * sync details to frames. - */ - syncDetails(callback) { - return this.sync.frames.forEach(frame => { + onPhase('details', frames.length); + await 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; + if (id in resources[project][type]) { + frame.title = resources[project][type][id].data.title; } }); - } - syncUpdate(callback) { - return this.sync.frames.forEach(async frame => { + onPhase('update', frames.length); + await frames.forEach(async frame => { let time = frame.duration, project = frame.project, type = frame.resource.type, @@ -144,17 +144,19 @@ class Timekeeper { time = Math.ceil(frame.duration) - parseInt(frame.notes.reduce((n, m) => (n + m.time), 0)); try { - await this._addTime(frame, time); + await this._addTime(resources, frame, time); } catch (error) { throw new Error(`Could not update ${type} ${id} on ${project}: ${error.message ?? error}`); } - if (callback) callback(); + onProgress(); }); + + return frames; } - async _addTime(frame, time) { - let resource = this.sync.resources[frame.project][frame.resource.type][frame.resource.id]; + async _addTime(resources, frame, time) { + let resource = resources[frame.project][frame.resource.type][frame.resource.id]; let createdNote = await resource.createTime(Math.ceil(time), frame._stop, frame.note); let noteid = createdNote ?.id?.split('/')?.pop(); From ad064957720c49366a0a75156499758992c9fb32 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:18:19 +0200 Subject: [PATCH 55/72] refactor timelogs --- spec/reporting/timelogs.spec.js | 106 +++++++++++++++++++++++++++ src/reporting/api/report.js | 124 ++------------------------------ src/reporting/api/timelogs.js | 101 ++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 117 deletions(-) create mode 100644 spec/reporting/timelogs.spec.js create mode 100644 src/reporting/api/timelogs.js diff --git a/spec/reporting/timelogs.spec.js b/spec/reporting/timelogs.spec.js new file mode 100644 index 0000000..b5c8abe --- /dev/null +++ b/spec/reporting/timelogs.spec.js @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import dayjs from '../../src/core/dayjs.js'; +import fetchTimelogs, { timelogsFor } from '../../src/reporting/api/timelogs.js'; + +function page(nodes, hasNextPage, endCursor = null) { + return { + body: { + data: { + project: { + timelogs: { + pageInfo: { hasNextPage, endCursor }, + nodes + } + } + } + } + }; +} + +function node(id) { + return { id, user: { username: 'alice' }, spentAt: '2026-01-01', timeSpent: 60 }; +} + +describe('fetchTimelogs', () => { + let client, from, to; + + beforeEach(() => { + client = { graphQL: sinon.stub() }; + from = dayjs('2026-01-01'); + to = dayjs('2026-01-31'); + }); + + it('returns the flat, ordered concatenation of all pages', async () => { + client.graphQL + .onCall(0).resolves(page([node(1), node(2)], true, 'cursor-1')) + .onCall(1).resolves(page([node(3)], false)); + + const timelogs = await fetchTimelogs(client, 'group/project', from, to); + + expect(timelogs.map(t => t.id)).to.deep.equal([1, 2, 3]); + }); + + it('requests each page with the previous page\'s end cursor, in order', async () => { + client.graphQL + .onCall(0).resolves(page([node(1)], true, 'cursor-1')) + .onCall(1).resolves(page([node(2)], false)); + + await fetchTimelogs(client, 'group/project', from, to); + + expect(client.graphQL.firstCall.args[0].variables.after).to.equal(''); + expect(client.graphQL.secondCall.args[0].variables.after).to.equal('cursor-1'); + expect(client.graphQL.firstCall.args[0].variables.project).to.equal('group/project'); + }); + + it('returns an empty array when the response has errors', async () => { + client.graphQL.resolves({ body: { errors: [{ message: 'nope' }] } }); + + const timelogs = await fetchTimelogs(client, 'group/project', from, to); + + expect(timelogs).to.deep.equal([]); + }); + + it('returns an empty array when nodes is missing', async () => { + client.graphQL.resolves({ body: { data: { project: { timelogs: { pageInfo: { hasNextPage: false } } } } } }); + + const timelogs = await fetchTimelogs(client, 'group/project', from, to); + + expect(timelogs).to.deep.equal([]); + }); + + it('stops after a single page when hasNextPage is false', async () => { + client.graphQL.resolves(page([node(1)], false)); + + await fetchTimelogs(client, 'group/project', from, to); + + expect(client.graphQL.callCount).to.equal(1); + }); +}); + +describe('timelogsFor', () => { + const timelogs = [ + { issues: { iid: 1, projectId: 10 }, id: 'a' }, + { issues: { iid: 2, projectId: 10 }, id: 'b' }, + { mergeRequests: { iid: 1, projectId: 10 }, id: 'c' }, + { issues: { iid: 1, projectId: 99 }, id: 'd' } // same iid, different project + ]; + + it('matches issues by iid and project_id', () => { + const matches = timelogsFor(timelogs, 'issues', { iid: 1, project_id: 10 }); + + expect(matches.map(t => t.id)).to.deep.equal(['a']); + }); + + it('matches merge requests by iid and project_id, independently of issues', () => { + const matches = timelogsFor(timelogs, 'mergeRequests', { iid: 1, project_id: 10 }); + + expect(matches.map(t => t.id)).to.deep.equal(['c']); + }); + + it('excludes entries from a different project sharing the same iid', () => { + const matches = timelogsFor(timelogs, 'issues', { iid: 1, project_id: 999 }); + + expect(matches).to.deep.equal([]); + }); +}); diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 18f41aa..3d5dbb5 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -4,6 +4,7 @@ import parallel from '../../core/parallel.js'; import Issue from '../../core/issue.js'; import MergeRequest from '../../core/mergeRequest.js'; import Project from './project.js'; +import fetchTimelogs, {timelogsFor} from './timelogs.js'; /** * report model @@ -113,122 +114,14 @@ class Report { return issues.filter(issue => this.config.get('showWithoutTimes') || (issue.times && issue.times.length > 0)); } - - - /** - * starts loading the timelogs data page after the cursor into the array timelogs and recurse to the next page as soon as results are received - * sets this.timelogs when the last page is received. - * @param {String} cursor - * @param {Array} timelogs + * fetch every timelog for this project in the configured date range. + * @returns {Promise} */ - getTimelogPage(cursor, timelogs) { - if(!timelogs) { - timelogs = []; - } - - const query = ` - query ($project: ID!, $after: String, $entryPerPage: Int, - $startTime:Time, $endTime:Time){ - project(fullPath: $project) { - name - timelogs(startTime: $startTime, endTime: $endTime, - first:$entryPerPage, after: $after) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - user { - username - } - spentAt - timeSpent - summary - note { - body - url - } - mergeRequests:mergeRequest { - iid - projectId - title - } - issues:issue { - iid - projectId - title - } - project { - id - name - } - } - } - } - } - ` - - let request = { - "query": query, - "variables": { - "project": this.project.data.path_with_namespace, - "after": (cursor===undefined)?'':cursor, - "entryPerPage": 30, - "startTime": this.config.get('from').format("YYYY-M-D"), - "endTime": this.config.get('to').format("YYYY-M-D") - } - }; - - let promise = this.client.graphQL(request); - promise.then(response => { - if (response.body.errors) { - this.timelogs = []; - } else { - if (response.body.data.project.timelogs.nodes) { - // add timelogs - timelogs.push(response.body.data.project.timelogs.nodes); - if (response.body.data.project.timelogs.pageInfo.hasNextPage) { - // get next page - this.getTimelogPage(response.body.data.project.timelogs.pageInfo.endCursor, timelogs); - } - else { - // all pages loaded. combine chunks into single array. - let timelogsAggr = []; - timelogs.forEach((timelogchunk) => { - timelogchunk.forEach((timelog) => { - timelogsAggr.push(timelog); - }); - }); - this.timelogs = timelogsAggr; - } - } - else { - this.timelogs = []; - } - } - } - ); - } - - - waitForTimelogs(resolve) { - if (this.timelogs == null) { - setTimeout(this.waitForTimelogs.bind(this), 50, resolve); - } else { - resolve(); - } - - } - + async getTimelogs() { + this.timelogs = await fetchTimelogs(this.client, this.project.data.path_with_namespace, this.config.get('from'), this.config.get('to')); - getTimelogs() { - this.getTimelogPage(); - let prm = new Promise((resolve, reject) => { - this.waitForTimelogs(resolve); - }); - return prm; + return this.timelogs; } /** @@ -245,10 +138,7 @@ class Report { let item = new model(this.config, data, this.client); item.project_namespace = this.projects[item.project_id]; - item.recordTimelogs(this.timelogs.filter( - timelog => timelog[input] && - timelog[input].iid == data.iid && - timelog[input].projectId == data.project_id)); + item.recordTimelogs(timelogsFor(this.timelogs, input, data)); if (this.config.get('showWithoutTimes') || item.times.length > 0) { collect.push(item); diff --git a/src/reporting/api/timelogs.js b/src/reporting/api/timelogs.js new file mode 100644 index 0000000..daaa9b4 --- /dev/null +++ b/src/reporting/api/timelogs.js @@ -0,0 +1,101 @@ +/** + * Fetch every timelog for a project in [from, to), paginating through + * GitLab's GraphQL API with a plain await loop - no recursion, no polling. + * @param client GitlabClient (or anything with a graphQL(request) method) + * @param projectPath full path, e.g. "group/project" + * @param from dayjs + * @param to dayjs + * @returns {Promise} flat array of timelog nodes, in page order + */ +export default async function fetchTimelogs(client, projectPath, from, to) { + let timelogs = []; + let cursor = ''; + let hasNextPage = true; + + while (hasNextPage) { + const response = await client.graphQL(timelogQuery(projectPath, cursor, from, to)); + + if (response.body.errors) return []; + + const page = response.body.data?.project?.timelogs; + if (!page || !page.nodes) return []; + + timelogs.push(...page.nodes); + hasNextPage = page.pageInfo.hasNextPage; + cursor = page.pageInfo.endCursor; + } + + return timelogs; +} + +function timelogQuery(projectPath, cursor, from, to) { + const query = ` + query ($project: ID!, $after: String, $entryPerPage: Int, + $startTime:Time, $endTime:Time){ + project(fullPath: $project) { + name + timelogs(startTime: $startTime, endTime: $endTime, + first:$entryPerPage, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + user { + username + } + spentAt + timeSpent + summary + note { + body + url + } + mergeRequests:mergeRequest { + iid + projectId + title + } + issues:issue { + iid + projectId + title + } + project { + id + name + } + } + } + } + } + `; + + return { + query, + variables: { + project: projectPath, + after: cursor, + entryPerPage: 30, + startTime: from.format('YYYY-M-D'), + endTime: to.format('YYYY-M-D') + } + }; +} + +/** + * Timelogs belonging to the given issue/merge request. Pure - replaces the + * raw-shape join that used to live inline in Report.process(). + * @param timelogs flat array from fetchTimelogs + * @param input 'issues' or 'mergeRequests' + * @param data the issue/merge request's raw GitLab data (iid, project_id) + * @returns {Array} + */ +export function timelogsFor(timelogs, input, data) { + return timelogs.filter(timelog => + timelog[input] && + timelog[input].iid == data.iid && + timelog[input].projectId == data.project_id + ); +} From 85a615d72554c63ade31051a25d5f922cc4214ca Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:21:13 +0200 Subject: [PATCH 56/72] refactor filter to labels and moved issues --- spec/reporting/filters.spec.js | 38 ++++++++++++++++++++++++++++++++++ src/reporting/api/filters.js | 22 ++++++++++++++++++++ src/reporting/api/report.js | 11 ++++------ 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 spec/reporting/filters.spec.js create mode 100644 src/reporting/api/filters.js diff --git a/spec/reporting/filters.spec.js b/spec/reporting/filters.spec.js new file mode 100644 index 0000000..7ebf5b6 --- /dev/null +++ b/spec/reporting/filters.spec.js @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { excludeByLabel, excludeMoved } from '../../src/reporting/api/filters.js'; + +function item(iid, labels = []) { + return { iid, labels }; +} + +describe('excludeByLabel', () => { + it('returns items unchanged when there are no exclude labels', () => { + const items = [item(1, ['bug'])]; + + expect(excludeByLabel(items, null)).to.deep.equal(items); + expect(excludeByLabel(items, undefined)).to.deep.equal(items); + expect(excludeByLabel(items, false)).to.deep.equal(items); + }); + + it('drops items carrying any of the excluded labels', () => { + const items = [item(1, ['bug']), item(2, ['wontfix']), item(3, ['bug', 'wontfix']), item(4, [])]; + + const result = excludeByLabel(items, ['wontfix']); + + expect(result.map(i => i.iid)).to.deep.equal([1, 4]); + }); +}); + +describe('excludeMoved', () => { + it('drops issues that have been moved to another project', () => { + const issues = [ + { iid: 1, moved_to_id: null }, + { iid: 2, moved_to_id: 42 }, + { iid: 3 } + ]; + + const result = excludeMoved(issues); + + expect(result.map(i => i.iid)).to.deep.equal([1, 3]); + }); +}); diff --git a/src/reporting/api/filters.js b/src/reporting/api/filters.js new file mode 100644 index 0000000..b5e3016 --- /dev/null +++ b/src/reporting/api/filters.js @@ -0,0 +1,22 @@ +/** + * Items (issues or merge requests) that carry none of the given + * excluded labels. Pure - replaces the raw-label filter that used to + * live inline in Report.getIssues/getMergeRequests. + * @param items raw GitLab issue/merge request objects (have .labels) + * @param excludeLabels array of label names, or falsy for "exclude nothing" + * @returns {Array} + */ +export function excludeByLabel(items, excludeLabels) { + if (!excludeLabels) return items; + + return items.filter(item => excludeLabels.filter(label => item.labels.includes(label)).length === 0); +} + +/** + * Issues that have not been moved to another project. + * @param issues raw GitLab issue objects (have .moved_to_id) + * @returns {Array} + */ +export function excludeMoved(issues) { + return issues.filter(issue => issue.moved_to_id == null); +} diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 3d5dbb5..d60471a 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -5,6 +5,7 @@ import Issue from '../../core/issue.js'; import MergeRequest from '../../core/mergeRequest.js'; import Project from './project.js'; import fetchTimelogs, {timelogsFor} from './timelogs.js'; +import {excludeByLabel, excludeMoved} from './filters.js'; /** * report model @@ -84,9 +85,7 @@ class Report { getMergeRequests() { let promise = this.client.all(`projects/${this.project.id}/merge_requests${this.params()}`); let excludes = this.config.get('excludeByLabels'); - promise.then(mergeRequests => this.mergeRequests = mergeRequests.filter(mr => ( - (!excludes || excludes.filter(l=>mr.labels.includes(l)).length==0) // keep all merge requests not including a exclude label - ))); + promise.then(mergeRequests => this.mergeRequests = excludeByLabel(mergeRequests, excludes)); return promise; } @@ -98,10 +97,8 @@ class Report { getIssues() { let promise = this.client.all(`projects/${this.project.id}/issues${this.params()}`); let excludes = this.config.get('excludeByLabels'); - promise.then(issues => this.issues = issues.filter(issue => ( - issue.moved_to_id == null && // filter moved issues in any case - (!excludes || excludes.filter(l=>issue.labels.includes(l)).length==0) // keep all issues not including a exclude label - ))); + promise.then(issues => this.issues = excludeByLabel(excludeMoved(issues), excludes)); + return promise; } From f0bd5b2f6f4b256d11408791bd768f4d3c5d3093 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:30:16 +0200 Subject: [PATCH 57/72] refactor and unify free/half price in reporting --- spec/core/billing.spec.js | 35 +++++++++++++++++++++++++++++++++++ spec/output/base.spec.js | 15 ++++++--------- src/core/billing.js | 15 +++++++++++++++ src/core/task.js | 27 +++------------------------ src/reporting/stats.js | 10 ++-------- 5 files changed, 61 insertions(+), 41 deletions(-) create mode 100644 spec/core/billing.spec.js create mode 100644 src/core/billing.js diff --git a/spec/core/billing.spec.js b/spec/core/billing.spec.js new file mode 100644 index 0000000..2468214 --- /dev/null +++ b/spec/core/billing.spec.js @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import Config from '../../src/core/config.js'; +import chargeRatio from '../../src/core/billing.js'; + +describe('chargeRatio', () => { + let config; + + beforeEach(() => { + config = new Config(); + config.set('freeLabels', ['pro bono']); + config.set('halfPriceLabels', ['discount']); + }); + + it('is 0.0 when a label matches freeLabels', () => { + expect(chargeRatio(['pro bono', 'bug'], config)).to.equal(0.0); + }); + + it('is 0.5 when a label matches halfPriceLabels', () => { + expect(chargeRatio(['discount', 'bug'], config)).to.equal(0.5); + }); + + it('is 1.0 when no label matches', () => { + expect(chargeRatio(['bug'], config)).to.equal(1.0); + }); + + it('free wins over half price when both match', () => { + expect(chargeRatio(['pro bono', 'discount'], config)).to.equal(0.0); + }); + + it('treats missing freeLabels/halfPriceLabels config as empty', () => { + const bareConfig = new Config(); + + expect(chargeRatio(['pro bono'], bareConfig)).to.equal(1.0); + }); +}); diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js index 6f562f6..d796919 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -64,14 +64,11 @@ describe('calculateStats', () => { 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']); - + it('tracks free and half price time by chargeRatio, set on each time record by Task.recordTimelogs', () => { 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 })] }) + makeIssue({ iid: 1, times: [makeTime({ seconds: 3600, chargeRatio: 0.0 })] }), + makeIssue({ iid: 2, times: [makeTime({ seconds: 1800, chargeRatio: 0.5 })] }), + makeIssue({ iid: 3, times: [makeTime({ seconds: 60, chargeRatio: 1.0 })] }) ]); expect(output.spentFree).to.equal(3600); @@ -79,9 +76,9 @@ describe('calculateStats', () => { expect(output.spent).to.equal(5460); }); - it('treats missing free/half price label config as empty', () => { + it('treats full-price (chargeRatio 1.0) time as neither free nor half price', () => { const output = calculate([ - makeIssue({ iid: 1, labels: ['bug'], times: [makeTime({ seconds: 3600 })] }) + makeIssue({ iid: 1, times: [makeTime({ seconds: 3600, chargeRatio: 1.0 })] }) ]); expect(output.spentFree).to.equal(0); diff --git a/src/core/billing.js b/src/core/billing.js new file mode 100644 index 0000000..0c31c13 --- /dev/null +++ b/src/core/billing.js @@ -0,0 +1,15 @@ +/** + * Charge ratio for a set of GitLab labels, driven by the freeLabels/ + * halfPriceLabels config. Free wins over half-price if both match. + * @param {string[]} labels + * @param config + * @returns {number} 0.0, 0.5 or 1.0 + */ +export default function chargeRatio(labels, config) { + let free = config.get('freeLabels') ?? []; + let halfPrice = config.get('halfPriceLabels') ?? []; + + if (labels.some(label => free.includes(label))) return 0.0; + if (labels.some(label => halfPrice.includes(label))) return 0.5; + return 1.0; +} diff --git a/src/core/task.js b/src/core/task.js index 03e6434..090b33d 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,6 +1,7 @@ import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; import Time from './time.js'; +import chargeRatio from './billing.js'; class Task { constructor(config, data = {}, client = new GitlabClient(config), type) { @@ -157,29 +158,7 @@ class Task { } recordTimelogs(timelogs){ - let spentFreeLabels = this.config.get('freeLabels'); - if(undefined === spentFreeLabels) { - spentFreeLabels = []; - } - let spentHalfPriceLabels = this.config.get('halfPriceLabels'); - if(undefined === spentHalfPriceLabels) { - spentHalfPriceLabels = []; - } - - let free = false; - let halfPrice = false; - this.labels.forEach(label => { - spentFreeLabels.forEach(freeLabel => { - free |= (freeLabel == label); - }); - }); - this.labels.forEach(label => { - spentHalfPriceLabels.forEach(halfPriceLabel => { - halfPrice |= (halfPriceLabel == label); - }); - }); - - let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); + let ratio = chargeRatio(this.labels, this.config); let times = [], timeSpent = 0, @@ -198,7 +177,7 @@ class Task { time.seconds = timelog.timeSpent; time.project_namespace = this.project_namespace; time.note = timelog.note && timelog.note.body ? timelog.note.body : null; - time.chargeRatio = chargeRatio; + time.chargeRatio = ratio; // only include times by the configured user if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; diff --git a/src/reporting/stats.js b/src/reporting/stats.js index 6ae5756..8325aba 100644 --- a/src/reporting/stats.js +++ b/src/reporting/stats.js @@ -26,14 +26,8 @@ export default function calculateStats(config, report) { 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 let issueDays = {}; @@ -71,10 +65,10 @@ export default function calculateStats(config, report) { spent += time.seconds; - if (free) { + if (time.chargeRatio === 0) { spentFree += time.seconds; } - if (halfPrice) { + if (time.chargeRatio === 0.5) { spentHalfPrice += time.seconds; } times.push(time); From d3af4e30aff00bba46fd89f1a84ca50053d14242 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:39:43 +0200 Subject: [PATCH 58/72] refactor config --- spec/core/configFile.spec.js | 129 +++++++++++++++++++++++++++++++++++ src/core/configFile.js | 63 +++++++++++++++++ src/core/file-config.js | 103 +++------------------------- 3 files changed, 200 insertions(+), 95 deletions(-) create mode 100644 spec/core/configFile.spec.js create mode 100644 src/core/configFile.js diff --git a/spec/core/configFile.spec.js b/spec/core/configFile.spec.js new file mode 100644 index 0000000..e5126d9 --- /dev/null +++ b/spec/core/configFile.spec.js @@ -0,0 +1,129 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { expect } from 'chai'; +import { findLocalConfigDir, readConfigFile, resolveLocalConfig } from '../../src/core/configFile.js'; + +describe('findLocalConfigDir', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gtt-configfile-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('finds the file in the starting directory', () => { + fs.writeFileSync(path.join(dir, '.gtt.yml'), ''); + + expect(findLocalConfigDir(dir, '.gtt.yml')).to.equal(dir); + }); + + it('walks up through parent directories to find the file', () => { + fs.writeFileSync(path.join(dir, '.gtt.yml'), ''); + const nested = path.join(dir, 'a', 'b', 'c'); + fs.mkdirSync(nested, { recursive: true }); + + expect(findLocalConfigDir(nested, '.gtt.yml')).to.equal(dir); + }); + + it('returns null when no config file is found up to the root', () => { + const nested = path.join(dir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + + expect(findLocalConfigDir(nested, '.this-file-does-not-exist.yml')).to.equal(null); + }); +}); + +describe('readConfigFile', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gtt-configfile-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('parses a valid YAML file', () => { + const file = path.join(dir, 'config.yml'); + fs.writeFileSync(file, 'token: abc\nurl: https://example.com\n'); + + expect(readConfigFile(file)).to.deep.equal({ token: 'abc', url: 'https://example.com' }); + }); + + it('throws a contextualized error for invalid YAML', () => { + const file = path.join(dir, 'broken.yml'); + fs.writeFileSync(file, 'token: [unclosed'); + + expect(() => readConfigFile(file)).to.throw(new RegExp(`Error parsing configuration: "${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); + }); + + it('throws a contextualized error for a missing file', () => { + const file = path.join(dir, 'missing.yml'); + + expect(() => readConfigFile(file)).to.throw(new RegExp(`Error parsing configuration: "${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); + }); +}); + +describe('resolveLocalConfig', () => { + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gtt-configfile-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function write(name, content) { + const file = path.join(dir, name); + fs.writeFileSync(file, content); + return file; + } + + it('returns the local config as-is when extend is explicitly disabled', () => { + const local = write('local.yml', 'extend: false\ntoken: local-token\n'); + const global = write('global.yml', 'token: global-token\nurl: https://global.example\n'); + + expect(resolveLocalConfig(local, global)).to.deep.equal({ extend: false, token: 'local-token' }); + }); + + it('defaults to extending the global config when extend is not specified', () => { + const local = write('local.yml', 'token: local-token\n'); + const global = write('global.yml', 'token: global-token\nurl: https://global.example\n'); + + expect(resolveLocalConfig(local, global)).to.deep.equal({ + extend: true, + token: 'local-token', + url: 'https://global.example' + }); + }); + + it('merges global underneath local when extend is true (the default)', () => { + const local = write('local.yml', 'extend: true\ntoken: local-token\n'); + const global = write('global.yml', 'token: global-token\nurl: https://global.example\n'); + + expect(resolveLocalConfig(local, global)).to.deep.equal({ + extend: true, + token: 'local-token', + url: 'https://global.example' + }); + }); + + it('merges an explicitly named file underneath local when extend is a path', () => { + const other = write('other.yml', 'token: other-token\nurl: https://other.example\n'); + const local = write('local.yml', `extend: ${other}\ntoken: local-token\n`); + const global = write('global.yml', 'token: global-token\n'); + + expect(resolveLocalConfig(local, global)).to.deep.equal({ + extend: other, + token: 'local-token', + url: 'https://other.example' + }); + }); +}); diff --git a/src/core/configFile.js b/src/core/configFile.js new file mode 100644 index 0000000..accc9d5 --- /dev/null +++ b/src/core/configFile.js @@ -0,0 +1,63 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { load as yamlLoad } from 'js-yaml'; +import Fs from './filesystem.js'; + +/** + * Walk up from startDir looking for a file named `filename`. + * @param startDir + * @param filename + * @returns {string|null} the directory it was found in, or null + */ +export function findLocalConfigDir(startDir, filename) { + if (fs.existsSync(Fs.join(startDir, filename))) return startDir; + + const root = (os.platform() === 'win32') ? process.cwd().split(path.sep)[0] + '\\' : '/'; + let dir = startDir; + + while (dir) { + dir = path.dirname(dir); + if (dir === root) dir = ''; + if (fs.existsSync(Fs.join(dir, filename))) return dir; + } + + return null; +} + +/** + * Read and parse one YAML config file. + * @param file + * @returns {Object} + */ +export function readConfigFile(file) { + try { + return yamlLoad(fs.readFileSync(file, 'utf8')); + } catch (e) { + e.message = `Error parsing configuration: "${file}": ${e.message}`; + throw e; + } +} + +/** + * Resolve a local config against the extend: chaining rule: local + * overrides global (extend: true, the default), local overrides an + * explicitly named file (extend: ), or local stands alone. + * @param localFile + * @param globalFile + * @returns {Object} + */ +export function resolveLocalConfig(localFile, globalFile) { + let local = Object.assign({extend: true}, readConfigFile(localFile)); + + if (local.extend === true) { + let global = readConfigFile(globalFile); + return Object.assign(global ? global : {}, local); + } + + if (local.extend) { + return Object.assign(readConfigFile(local.extend), local); + } + + return local; +} diff --git a/src/core/file-config.js b/src/core/file-config.js index 02969a0..09b49af 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -1,14 +1,9 @@ import fs from 'fs'; -import path from 'path'; -import os from 'os'; import Config from './config.js'; -import { load as yamlLoad, dump as yamlDump } from 'js-yaml'; -import hash from 'hash-sum'; +import { dump as yamlDump } from 'js-yaml'; import Fs from './filesystem.js'; import envPaths from 'env-paths'; - -const readYaml = file => yamlLoad(fs.readFileSync(file, 'utf8')); - +import { findLocalConfigDir, readConfigFile, resolveLocalConfig } from './configFile.js'; /** * file config with local and global configuration files @@ -25,71 +20,19 @@ class FileConfig extends Config { this.assertGlobalConfig(recover); this.workDir = workDir; if(!recover) { - this.data = Object.assign(this.data, this.localExists() ? this.parseLocal() : this.parseGlobal()); + this.data = Object.assign(this.data, this.localExists() ? resolveLocalConfig(this.local, this.global) : readConfigFile(this.global)); } if (!fs.existsSync(this.frameDir)) fs.mkdirSync(this.frameDir, { recursive: true }); - this.cache = { - delete: this._cacheDelete, - get: this._cacheGet, - set: this._cacheSet, - dir: this.cacheDir - }; - } - - /** - * parse the global config - * @returns {Object} - */ - parseGlobal() { - try { - return readYaml(this.global); - } catch (e) { - e.message = `Error parsing configuration: "${this.global}": ${e.message}`; - throw e; - } - } - - /** - * parse the local config - * @returns {Object} - */ - parseLocal() { - let local; - try { - local = Object.assign({extend: true}, readYaml(this.local)); - } catch (e) { - e.message = `Error parsing configuration: "${this.local}": ${e.message}`; - throw e; - } - - if (local.extend === true) { - let global = this.parseGlobal(); - local = Object.assign(global ? global : {}, local); - } else if (local.extend) { - try { - local = Object.assign(readYaml(local.extend), local); - } catch (e) { - e.message = `Error parsing configuration: "${local.extend}": ${e.message}`; - throw e; - } - } - - return local; } localExists() { if (fs.existsSync(this.local)) return true; - let root = (os.platform() === "win32") ? process.cwd().split(path.sep)[0] + "\\" : "/"; - let workDir = this.workDir; - while (workDir) { - workDir = path.dirname(workDir); - if (workDir === root) workDir = ''; - if (fs.existsSync(Fs.join(workDir, this.localConfigFile))) { - this.workDir = workDir; - return true; - } - } + let found = findLocalConfigDir(this.workDir, this.localConfigFile); + if (found === null) return false; + + this.workDir = found; + return true; } assertGlobalConfig(recover) { @@ -97,10 +40,7 @@ class FileConfig extends Config { fs.renameSync(this.oldGlobalDir, this.globalDir); } - - if (!fs.existsSync(this.globalDir)) fs.mkdirSync(this.globalDir, { recursive: true }); - if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir, { recursive: true }); if (recover && !fs.existsSync(this.global)) fs.appendFileSync(this.global, yamlDump(this.data)); } @@ -108,29 +48,6 @@ class FileConfig extends Config { if (!this.localExists()) fs.appendFileSync(this.local, ''); } - _cacheDelete(key) { - let file = Fs.join(this.dir, hash(key)); - if (!fs.existsSync(file)) return false; - - return fs.unlinkSync(file); - } - - _cacheGet(key) { - let file = Fs.join(this.dir, hash(key)); - if (!fs.existsSync(file)) return false; - - return JSON.parse(fs.readFileSync(file)); - } - - _cacheSet(key, value) { - let file = Fs.join(this.dir, hash(key)); - if (fs.existsSync(file)) fs.unlinkSync(file); - fs.appendFile(file, JSON.stringify(value), () => { - }); - - return value; - } - get localConfigFile() { return '.gtt.yml'; } @@ -150,10 +67,6 @@ class FileConfig extends Config { return Fs.join(this.globalDir, 'frames'); } - get cacheDir() { - return Fs.join(this.globalDir, 'cache') - } - get global() { return Fs.join(this.globalDir, 'config.yml'); } From 93d38178207f85b0dd4753f5d53deb7503157226 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:44:44 +0200 Subject: [PATCH 59/72] remove legacy dir migration of config file --- src/core/file-config.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/core/file-config.js b/src/core/file-config.js index 09b49af..ef6498d 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -36,10 +36,6 @@ class FileConfig extends Config { } assertGlobalConfig(recover) { - if(!fs.existsSync(this.globalDir) && fs.existsSync(this.oldGlobalDir)) { - fs.renameSync(this.oldGlobalDir, this.globalDir); - } - if (!fs.existsSync(this.globalDir)) fs.mkdirSync(this.globalDir, { recursive: true }); if (recover && !fs.existsSync(this.global)) fs.appendFileSync(this.global, yamlDump(this.data)); } @@ -52,10 +48,6 @@ class FileConfig extends Config { return '.gtt.yml'; } - get oldGlobalDir() { - return Fs.join(process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME'], '.gtt'); - } - get globalDir() { return envPaths(".gtt", {suffix:""}).data; } From 0375bdecfd5d0c2a4da200475f0866097c7e9163 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 22:59:08 +0200 Subject: [PATCH 60/72] handle unhandled rejections, exit in cli / command layer only --- spec/core/cli.spec.js | 80 +++++++++++++++++++++++++++++++++++++++++++ src/core/cli.js | 14 +++++++- src/gtt.js | 13 ++++++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 spec/core/cli.spec.js diff --git a/spec/core/cli.spec.js b/spec/core/cli.spec.js new file mode 100644 index 0000000..9964ee5 --- /dev/null +++ b/spec/core/cli.spec.js @@ -0,0 +1,80 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import Cli, { CliExitError } from '../../src/core/cli.js'; + +describe('Cli', () => { + let exitStub, writeStub; + + beforeEach(() => { + Cli.quiet = false; + Cli.verbose = false; + exitStub = sinon.stub(process, 'exit'); + writeStub = sinon.stub(process.stdout, 'write'); + }); + + afterEach(() => { + exitStub.restore(); + writeStub.restore(); + }); + + describe('error', () => { + it('throws a CliExitError instead of exiting the process', () => { + expect(() => Cli.error('boom')).to.throw(CliExitError, 'boom'); + expect(exitStub.called).to.be.false; + }); + + it('carries exit code 1', () => { + try { + Cli.error('boom'); + } catch (e) { + expect(e.code).to.equal(1); + } + }); + + it('prints the error message before throwing', () => { + expect(() => Cli.error('boom')).to.throw(); + expect(writeStub.calledWithMatch(/Error:.*boom/)).to.be.true; + }); + + it('extracts .message from an Error instance passed as message, keeping the original for verbose logging', () => { + const original = new Error('underlying failure'); + let caught; + + try { + Cli.error(original); + } catch (e) { + caught = e; + } + + expect(caught.message).to.equal('underlying failure'); + }); + }); + + describe('x', () => { + it('throws (via Cli.error) when given a message', () => { + expect(() => Cli.x('boom')).to.throw(CliExitError, 'boom'); + }); + + it('does not throw when given no message - just stops the spinner', async () => { + await Cli.x(); + + expect(exitStub.called).to.be.false; + }); + }); + + describe('out/warn', () => { + it('out writes to stdout unless quiet', () => { + Cli.out('hello'); + expect(writeStub.calledWith('hello')).to.be.true; + + writeStub.resetHistory(); + Cli.quiet = true; + Cli.out('hello'); + expect(writeStub.called).to.be.false; + }); + + it('warn does not throw', () => { + expect(() => Cli.warn('careful')).to.not.throw(); + }); + }); +}); diff --git a/src/core/cli.js b/src/core/cli.js index 4256221..a9af8bd 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -7,6 +7,18 @@ const spinnerFrames = ['|', '/', '-', '\\']; let spinnerIndex = 0; const spinner = { next: () => spinnerFrames[spinnerIndex++ % spinnerFrames.length] }; +/** + * thrown by Cli.error - carries the exit code the process should end + * with once the caller (or the top-level unhandledRejection handler in + * gtt.js) is done unwinding. + */ +export class CliExitError extends Error { + constructor(message, code = 1) { + super(message); + this.code = code; + } +} + /** * Cli helper */ @@ -206,7 +218,7 @@ class Cli { Cli.out(`Error: ${pc.red(message)}` + '\n'); if (error && Cli.verbose) console.log(error); - process.exit(1); + throw new CliExitError(message, 1); } /** diff --git a/src/gtt.js b/src/gtt.js index cdeeab7..86dbe91 100755 --- a/src/gtt.js +++ b/src/gtt.js @@ -5,7 +5,7 @@ import version from './version.js'; import { program } from 'commander'; import Config from './core/file-config.js'; -import Cli from './core/cli.js'; +import Cli, { CliExitError } from './core/cli.js'; import config from './core/commands/config.js'; import start from './timekeeping/commands/start.js'; import create from './timekeeping/commands/create.js'; @@ -36,6 +36,17 @@ function loadConfig(recover = false) { } } +// Cli.error() throws CliExitError instead of exiting directly, so it's +// safe to call in tests. Most command actions are neither async nor +// awaited by commander, so their error paths never reach a .catch() of +// their own - this is the one place that actually ends the process. +process.on('unhandledRejection', error => { + if (error instanceof CliExitError) process.exit(error.code); + + console.error(error); + process.exit(1); +}); + program .version(version) .addCommand(start(loadConfig)) From a5d766bcd7092f4ac7f551d673d76bfa93aff2d6 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 23:09:52 +0200 Subject: [PATCH 61/72] fix command description --- src/core/commands/config.js | 3 ++- src/reporting/commands/report.js | 3 ++- src/timekeeping/commands/archive.js | 3 ++- src/timekeeping/commands/cancel.js | 3 ++- src/timekeeping/commands/create.js | 3 ++- src/timekeeping/commands/delete.js | 3 ++- src/timekeeping/commands/edit.js | 3 ++- src/timekeeping/commands/list.js | 3 ++- src/timekeeping/commands/log.js | 3 ++- src/timekeeping/commands/resume.js | 3 ++- src/timekeeping/commands/start.js | 3 ++- src/timekeeping/commands/status.js | 3 ++- src/timekeeping/commands/stop.js | 3 ++- src/timekeeping/commands/sync.js | 3 ++- 14 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/core/commands/config.js b/src/core/commands/config.js index e86f9d9..26243eb 100755 --- a/src/core/commands/config.js +++ b/src/core/commands/config.js @@ -2,7 +2,8 @@ import {Command} from 'commander'; import Fs from '../filesystem.js'; function cfgCmd(configLoader) { - const cfgCmd = new Command('config', 'edit the configuration file in your default editor') + const cfgCmd = new Command('config') + .description('edit the configuration file in your default editor') .option('-l, --local', 'edit the local configuration file') .action((options, program) => { diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index a4d0003..a012557 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -16,7 +16,8 @@ function collect(val, arr) { function report(configLoader) { - const report = new Command('report', 'generate a report for the given project and issues') + const report = new Command('report') + .description('generate a report for the given project and issues') .arguments('[project] [ids...]') .option('-e --type ', 'specify the query type: project, user, group') .option('--subgroups', 'include sub groups') diff --git a/src/timekeeping/commands/archive.js b/src/timekeeping/commands/archive.js index e9c3ca7..a29ccda 100644 --- a/src/timekeeping/commands/archive.js +++ b/src/timekeeping/commands/archive.js @@ -6,7 +6,8 @@ import Zip from '../../core/zip.js'; import Timekeeper from '../timekeeper.js'; function archive(configLoader) { - const archive = new Command('archive', 'archive synced time records into yearly zip files (filesYYYY.zip), one folder per month') + const archive = new Command('archive') + .description('archive synced time records into yearly zip files (filesYYYY.zip), one folder per month') .option('--year ', 'only archive the given year') .option('--verbose', 'show verbose output') .action((options, program) => { diff --git a/src/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index 2a467a1..e13431b 100755 --- a/src/timekeeping/commands/cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -5,7 +5,8 @@ import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; function cancel(configLoader) { - const cancel = new Command('cancel', 'cancel and discard active monitoring time') + const cancel = new Command('cancel') + .description('cancel and discard active monitoring time') .option('--verbose', 'show verbose output') .action((options, program) => { diff --git a/src/timekeeping/commands/create.js b/src/timekeeping/commands/create.js index 13dd006..5a43195 100755 --- a/src/timekeeping/commands/create.js +++ b/src/timekeeping/commands/create.js @@ -6,7 +6,8 @@ import Timekeeper from '../timekeeper.js'; function create(configLoader) { - const create = new Command('create', 'start monitoring time for the given project and create a new issue or merge request with the given title') + const create = new Command('create') + .description('start monitoring time for the given project and create a new issue or merge request with the given title') .arguments('[project] [title]') .option('-t, --type ', 'specify resource type: issue, merge_request') .option('--verbose', 'show verbose output') diff --git a/src/timekeeping/commands/delete.js b/src/timekeeping/commands/delete.js index 1075b71..bdf3a6a 100755 --- a/src/timekeeping/commands/delete.js +++ b/src/timekeeping/commands/delete.js @@ -5,7 +5,8 @@ import Fs from '../../core/filesystem.js'; import pc from 'picocolors'; function delCmd(configLoader) { - const delCmd = new Command('delete', 'delete time record by the given id') + const delCmd = new Command('delete') + .description('delete time record by the given id') .arguments('[id]') .action((id, opts, program) => { diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 2bbe981..f463d17 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -214,7 +214,8 @@ function showInteractiveMenu(frames) { } function edit(configLoader) { - const edit = new Command('edit', 'edit time record by the given id') + const edit = new Command('edit') + .description('edit time record by the given id') .arguments('[id]') .option('-f, --following ', 'edit also the following (by ctime) of the given [id]') .option('-n, --listsize ', 'list size', 30) diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index 5da1ac6..6fcf93f 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -7,7 +7,8 @@ import cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; function list(configLoader) { - const list = new Command('list', 'list all open issues or merge requests') + const list = new Command('list') + .description('list all open issues or merge requests') .arguments('[project]') .option('--verbose', 'show verbose output') .option('-c, --closed', 'show closed issues (instead of opened only)') diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index 06e5a29..74705a9 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -6,7 +6,8 @@ import Time from '../../core/time.js'; import Timekeeper from '../timekeeper.js'; function log(configLoader) { - const log = new Command('log', 'log recorded time records') + const log = new Command('log') + .description('log recorded time records') .option('--verbose', 'show verbose output') .option('--hours_per_day ', 'hours per day for human readable time formats') .option('--time_format ', 'time format') diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index 885faf9..19636cc 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -23,7 +23,8 @@ function resumeFrame(timekeeper, frame) { } function resume(configLoader) { - const resume = new Command('resume', 'resume monitoring time for last stopped record') + const resume = new Command('resume') + .description('resume monitoring time for last stopped record') .arguments('[project]') .option('--verbose', 'show verbose output') .option('--ask', 'ask the activity to resume from the last entries, ignoring project') diff --git a/src/timekeeping/commands/start.js b/src/timekeeping/commands/start.js index 8a57b4c..b02e316 100755 --- a/src/timekeeping/commands/start.js +++ b/src/timekeeping/commands/start.js @@ -6,7 +6,8 @@ import Timekeeper from '../timekeeper.js'; function start(configLoader) { - const start = new Command('start', 'start monitoring time for the given project and resource id') + const start = new Command('start') + .description('start monitoring time for the given project and resource id') .arguments('[project] [id]') .option('-t, --type ', 'specify resource type: issue, merge_request') .option('-m', 'shorthand for --type=merge_request') diff --git a/src/timekeeping/commands/status.js b/src/timekeeping/commands/status.js index 6da9a38..242644d 100755 --- a/src/timekeeping/commands/status.js +++ b/src/timekeeping/commands/status.js @@ -6,7 +6,8 @@ import Timekeeper from '../timekeeper.js'; function status(configLoader) { - const status = new Command('status', 'shows if time monitoring is running') + const status = new Command('status') + .description('shows if time monitoring is running') .option('--verbose', 'show verbose output') .option('-s', 'short output') .action((options, program) => { diff --git a/src/timekeeping/commands/stop.js b/src/timekeeping/commands/stop.js index b7bf362..725238b 100755 --- a/src/timekeeping/commands/stop.js +++ b/src/timekeeping/commands/stop.js @@ -6,7 +6,8 @@ import Timekeeper from '../timekeeper.js'; function stop(configLoader) { - const stop = new Command('stop', 'stop monitoring time') + const stop = new Command('stop') + .description('stop monitoring time') .option('--verbose', 'show verbose output') .action((options, program) => { diff --git a/src/timekeeping/commands/sync.js b/src/timekeeping/commands/sync.js index 5e9f91c..ad3edce 100755 --- a/src/timekeeping/commands/sync.js +++ b/src/timekeeping/commands/sync.js @@ -4,7 +4,8 @@ import Timekeeper from '../timekeeper.js'; import Owner from '../../core/owner.js'; function sync(configLoader) { - const sync = new Command('sync', 'sync local time records to GitLab') + const sync = new Command('sync') + .description('sync local time records to GitLab') .option('--url ', 'URL to GitLabs API') .option('--token ', 'API access token') .option('--verbose', 'show verbose output') From 060f85c8a4fc497f66836663a973c224da000252 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 23:16:24 +0200 Subject: [PATCH 62/72] prepare for type checks --- package-lock.json | 376 ++++++++++++++++++++++++++- package.json | 5 +- src/core/cli.js | 11 +- src/reporting/output/base.js | 20 ++ src/reporting/reportConfigBuilder.js | 4 +- src/reporting/reportRunner.js | 2 +- src/timekeeping/timekeeper.js | 4 +- tsconfig.json | 14 + 8 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json index 688cf73..df74ffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,11 +33,13 @@ "gtt": "dist/gtt.cjs" }, "devDependencies": { + "@types/node": "^26.1.1", "@yao-pkg/pkg": "^6.21.0", "chai": "^6.2.2", "esbuild": "^0.28.1", "mocha": "^11.3.0", - "sinon": "^22.0.0" + "sinon": "^22.0.0", + "typescript": "^7.0.2" }, "engines": { "node": "24" @@ -879,6 +881,336 @@ "node": ">=4" } }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@yao-pkg/pkg": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.21.0.tgz", @@ -3520,6 +3852,41 @@ "node": ">=4" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -3530,6 +3897,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index 01d2b6e..c5ce7d1 100755 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "scripts": { "prebuild": "echo \"let version = '${npm_package_version}'; export default version;\" > src/version.js", "test": "NODE_ENV=test mocha 'spec/**/*.spec.js'", + "typecheck": "tsc --noEmit", "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", @@ -58,10 +59,12 @@ "throttled-queue": "^3.0.0" }, "devDependencies": { + "@types/node": "^26.1.1", "@yao-pkg/pkg": "^6.21.0", "chai": "^6.2.2", "esbuild": "^0.28.1", "mocha": "^11.3.0", - "sinon": "^22.0.0" + "sinon": "^22.0.0", + "typescript": "^7.0.2" } } diff --git a/src/core/cli.js b/src/core/cli.js index a9af8bd..b70f844 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -23,6 +23,13 @@ export class CliExitError extends Error { * Cli helper */ class Cli { + /** @type {boolean} */ + static quiet = false; + /** @type {boolean} */ + static verbose = false; + /** @type {any} */ + static active; + /* * emojis */ @@ -133,7 +140,7 @@ class Cli { let left; if (Cli.active.bar.curr > 0) { - let elapsed = Math.ceil((new Date() - Cli.active.started) / 1000); + let elapsed = Math.ceil((new Date().getTime() - Cli.active.started.getTime()) / 1000); left = ((elapsed / Cli.active.bar.curr) * (Cli.active.bar.total - Cli.active.bar.curr)) / 60; left = left < 1 ? `<1` : Math.ceil(left); } else { @@ -223,7 +230,7 @@ class Cli { /** * get a promise (for chaining promises) - * @returns {Promise} + * @returns {Promise} */ static promise() { return new Promise(resolve => { diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index ca6d560..0605bd4 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -13,6 +13,20 @@ const defaultFormats = { * exposes write/headline/toStdOut/toFile. Root of the output-format hierarchy. */ class Output { + // assigned in the constructor via Object.assign(this, calculateStats(...)) + /** @type {Array} */ times; + /** @type {Object} */ days; + /** @type {Object} */ daysMoment; + /** @type {Object} */ daysNew; + /** @type {Object} */ users; + /** @type {Object} */ projects; + /** @type {Object} */ stats; + /** @type {number} */ totalEstimate; + /** @type {number} */ totalSpent; + /** @type {number} */ spent; + /** @type {number} */ spentFree; + /** @type {number} */ spentHalfPrice; + /** * constructor * @param config @@ -30,6 +44,12 @@ class Output { this.formats = Object.assign(this.formats, value); } + // implemented by each output format (table/csv/markdown/invoice) + makeStats() {} + makeIssues() {} + makeMergeRequests() {} + makeRecords() {} + /** * print a headline * @param string diff --git a/src/reporting/reportConfigBuilder.js b/src/reporting/reportConfigBuilder.js index 257e2b7..f248387 100644 --- a/src/reporting/reportConfigBuilder.js +++ b/src/reporting/reportConfigBuilder.js @@ -3,10 +3,10 @@ import dayjs from '../core/dayjs.js'; /** * Apply report command options and positional args onto config, including * the --today/--this_week/--this_month/--last_month date shortcuts. - * @param config + * @param {import('../core/config.js').default} config * @param opts commander's program.opts() * @param args Args instance built from program.args - * @returns {Config} + * @returns {import('../core/config.js').default} */ export function buildReportConfig(config, opts, args) { config diff --git a/src/reporting/reportRunner.js b/src/reporting/reportRunner.js index 90cfda7..dab0818 100644 --- a/src/reporting/reportRunner.js +++ b/src/reporting/reportRunner.js @@ -165,7 +165,7 @@ async function makeOutput(config, master, reporter) { * @param config a config built by buildReportConfig * @param client GitlabClient * @param reporter progress/error reporter, shaped like Cli (list/mark/x/bar/advance/out/error + emoji getters) - * @returns {Promise} the rendered output, ready for toFile/toStdOut + * @returns {Promise} the rendered output, ready for toFile/toStdOut */ export async function runReport(config, client, reporter = Cli) { let owner = new Owner(config, client), diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 25262d5..3045540 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -89,8 +89,8 @@ class Timekeeper { * phases (resolve/details/update) - callers no longer choreograph them. * @param {FrameCollection} frames typically the result of pendingFrames() * @param {Object} [hooks] - * @param {function(phase: 'resolve'|'details'|'update', total: number)} [hooks.onPhase] called once per phase, before it starts - * @param {function()} [hooks.onProgress] called after each frame is resolved/updated + * @param {(phase: 'resolve'|'details'|'update', total: number) => void} [hooks.onPhase] called once per phase, before it starts + * @param {() => void} [hooks.onProgress] called after each frame is resolved/updated * @returns {Promise} the frames that were synced */ async sync(frames, {onPhase = () => {}, onProgress = () => {}} = {}) { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bed6d74 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "target": "esnext", + "module": "nodenext", + "moduleResolution": "nodenext", + "skipLibCheck": true, + "strict": false, + "types": ["node"] + }, + "include": ["src/**/*.js"] +} From 54456101300b045c2e12ea1cf5896323f538f654 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 23:24:16 +0200 Subject: [PATCH 63/72] refactor frame do not use different types on _start and _stop --- spec/models/frame.spec.js | 28 +++++++++ src/timekeeping/storage/frame.js | 70 +++++++++++++++++----- src/timekeeping/storage/frameCollection.js | 2 +- src/timekeeping/timekeeper.js | 6 +- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/spec/models/frame.spec.js b/spec/models/frame.spec.js index fa09d84..2ac69f1 100644 --- a/spec/models/frame.spec.js +++ b/spec/models/frame.spec.js @@ -77,4 +77,32 @@ describe('frame class', () => { frame.stop = '2026-01-05T11:30:00Z'; expect(frame.stop.isSame('2026-01-05T11:30:00Z')).to.equal(true); }); + + it('rejects a frame with no start date at all', () => { + const json = { + id: 'abc', + project: 'group/project', + resource: { id: 42, type: 'issue' }, + notes: [], + start: false, + stop: false, + timezone: 'UTC' + }; + + expect(() => Frame.fromJson(config, json)).to.throw(/Start date/); + }); + + it('persists start/stop as strings even without a timezone configured', () => { + config.set('timezone', false, true); + + const frame = new Frame(config, 42, 'issue').startMe(); + frame.stopMe(); + + expect(frame._start).to.be.a('string'); + expect(frame._stop).to.be.a('string'); + + const loaded = Frame.fromFile(config, frame.file); + expect(loaded.start.isSame(frame.start)).to.equal(true); + expect(loaded.stop.isSame(frame.stop)).to.equal(true); + }); }); diff --git a/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index d8771cc..6eb9ebc 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -4,41 +4,75 @@ import dayjs from '../../core/dayjs.js'; import Hashids from 'hashids'; const hashids = new Hashids(); +/** @typedef {import('dayjs').Dayjs} Dayjs */ +/** @typedef {import('../../core/file-config.js').default} Config */ +/** + * @typedef {Object} FrameJson + * @property {string} id + * @property {string} project + * @property {{id: string|number, type: string, new?: true}} resource + * @property {Array<{id: number, time: number}>} notes + * @property {string|false} start + * @property {string|false} stop + * @property {string|false} timezone + * @property {Dayjs|string} [modified] + * @property {string|null} [title] + * @property {string} [note] + */ + class Frame { + /** @type {string|null} not yet started until startMe()/the start setter runs */ + _start; + /** @type {string|null} still running until stopMe()/the stop setter runs */ + _stop; + /** @type {Dayjs|string|undefined} last-write timestamp, set on every write() */ + modified; + /** * constructor. - * @param config - * @param id - * @param type - * @param note + * @param {Config} config + * @param {string|number} id + * @param {string} type + * @param {string} [note] */ constructor(config, id, type, note) { this.config = config; this.project = config.get('project'); this.resource = {id, type}; - if(typeof id === 'string' || id instanceof String) + if(typeof id === 'string' || /** @type {any} */ (id) instanceof String) this.resource.new = true; this.id = Frame.generateId(); - this._start = false; - this._stop = false; + this._start = null; + this._stop = null; this.timezone = config.get('timezone'); this.notes = []; this._note = note; this._title = null; } + /** + * @param {Config} config + * @param {string} file + * @returns {Frame} + */ static fromFile(config, file) { - return Frame.fromJson(config, JSON.parse(fs.readFileSync(file))); + return Frame.fromJson(config, JSON.parse(fs.readFileSync(file, 'utf8'))); } + /** + * @param {Config} config + * @param {FrameJson} json + * @returns {Frame} + */ static fromJson(config, json) { let frame = new this(config, json.resource.id, json.resource.type, json.note); frame.project = json.project; frame.id = json.id; - frame._start = json.start; - frame._stop = json.stop; + // older frame files persisted the sentinel as `false`; normalize to null + frame._start = json.start || null; + frame._stop = json.stop || null; frame.notes = json.notes; frame.timezone = json.timezone; frame.modified = json.modified; @@ -49,7 +83,7 @@ class Frame { } validate() { - if(!dayjs(this._start).isValid()) + if(!this._start || !dayjs(this._start).isValid()) throw new Error(`Start date is not in a valid ISO date format!`); if(this._stop && !dayjs(this._stop).isValid()) @@ -60,7 +94,7 @@ class Frame { if(this.timezone) return dayjs().tz(this.timezone).format(); - return dayjs(); + return dayjs().format(); } startMe() { @@ -81,6 +115,7 @@ class Frame { * 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. + * @param {boolean} [skipModified] */ write(skipModified) { const tmpFile = `${this.file}.tmp`; @@ -101,6 +136,7 @@ class Frame { } get duration() { + // only meaningful once stopped - callers must guard frame.stop === null first return dayjs(this.stop).diff(this.start) / 1000; } @@ -108,21 +144,25 @@ class Frame { return this.start; } + /** @returns {Dayjs} invalid before startMe()/the start setter has run */ get start() { return this.timezone ? dayjs(this._start).tz(this.timezone) : dayjs(this._start); } + /** @param {string|Dayjs} value */ set start(value) { this._start = dayjs.isDayjs(value) ? value.format() : value; this.validate(); } + /** @returns {Dayjs|null} null while the frame is still running */ get stop() { - return this.timezone ? this._stop ? dayjs(this._stop).tz(this.timezone) : false : (this._stop ? dayjs(this._stop) : false ); + return this.timezone ? this._stop ? dayjs(this._stop).tz(this.timezone) : null : (this._stop ? dayjs(this._stop) : null); } + /** @param {string|Dayjs|null} value null clears the stop time (still running) */ set stop(value) { - this._stop = value ? (dayjs.isDayjs(value) ? value.format() : value) : false; + this._stop = value ? (dayjs.isDayjs(value) ? value.format() : value) : null; this.validate(); } @@ -148,7 +188,7 @@ class Frame { /** * generate a unique id - * @returns {number} + * @returns {string} */ static generateId() { return hashids.encode(new Date().getTime()); diff --git a/src/timekeeping/storage/frameCollection.js b/src/timekeeping/storage/frameCollection.js index 79332c7..534d175 100755 --- a/src/timekeeping/storage/frameCollection.js +++ b/src/timekeeping/storage/frameCollection.js @@ -28,7 +28,7 @@ class FrameCollection { let arr = []; this.frames.forEach(frame => { - if (frame.stop === false) { + if (frame.stop === null) { return false; } diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 3045540..85e725f 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -31,7 +31,7 @@ class Timekeeper { */ _currentId() { if (!Fs.exists(this._currentFile())) { - let running = new FrameCollection(this.config).frames.find(frame => frame.stop === false); + let running = new FrameCollection(this.config).frames.find(frame => frame.stop === null); Fs.writeText(this._currentFile(), running ? running.id : ''); } let id = Fs.readText(this._currentFile()).trim(); @@ -68,7 +68,7 @@ class Timekeeper { let grouped = {}; await new FrameCollection(this.config).forEach(frame => { - if (frame.stop === false) return; + if (frame.stop === null) return; let year = frame.date.format('YYYY'), month = frame.date.format('MM'); @@ -204,7 +204,7 @@ class Timekeeper { await new FrameCollection(this.config) .forEach(frame => { - if (frame.stop === false) return; + if (frame.stop === null) return; let date = frame.date.format('YYYY-MM-DD'); if (!frames[date]) frames[date] = []; From 78d9556d2bc4856cd91e2eea8aa100e973c44f12 Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 23:31:44 +0200 Subject: [PATCH 64/72] typecheck/typedocs --- src/core/args.js | 5 +++-- src/core/cli.js | 12 ++++++------ src/core/filesystem.js | 10 ++++++++-- src/core/gitlab-client.js | 7 +++++-- src/core/global.d.ts | 8 ++++++++ src/core/owner.js | 6 +++--- src/core/task.js | 3 +++ src/core/time.js | 13 ++++++++----- src/reporting/api/project.js | 4 ++-- src/reporting/api/report.js | 6 +++--- src/reporting/output/csv.js | 2 +- src/reporting/output/invoice.js | 2 +- src/reporting/output/markdown.js | 2 +- src/reporting/output/table.js | 2 +- src/timekeeping/commands/delete.js | 7 +++++-- src/timekeeping/commands/edit.js | 2 +- src/timekeeping/commands/resume.js | 4 ++-- tsconfig.json | 2 +- 18 files changed, 62 insertions(+), 35 deletions(-) create mode 100644 src/core/global.d.ts diff --git a/src/core/args.js b/src/core/args.js index d868e6d..211034e 100644 --- a/src/core/args.js +++ b/src/core/args.js @@ -4,7 +4,8 @@ class Args { constructor(args) { this.args = args; - this.data = []; + /** @type {{project?: string[], iids?: string[]}} */ + this.data = {}; } /** @@ -16,7 +17,7 @@ class Args { if (this.data.project) return this.data.project; - let projects = [...new Set(this.args.filter(arg => isNaN(new Number(arg))))]; + let projects = [...new Set(this.args.filter(arg => isNaN(Number(arg))))]; this.args = this.args.filter(arg => !projects.includes(arg)); if(projects.length == 0) diff --git a/src/core/cli.js b/src/core/cli.js index b70f844..6405a57 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -188,9 +188,9 @@ class Cli { /** * stop a list item with an x - * @param message - * @param error - * @returns {*} + * @param {string|Error|false} [message] + * @param {Error|false} [error] + * @returns {Promise} */ static x(message = false, error = false) { Cli.resolve(); @@ -210,9 +210,9 @@ class Cli { /** * show an error message - * @param message - * @param error - * @returns {*} + * @param {string|Error} message + * @param {Error|false} [error] + * @returns {never} */ static error(message, error) { Cli.resolve(); diff --git a/src/core/filesystem.js b/src/core/filesystem.js index f084b6c..69f965a 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -40,15 +40,21 @@ class Filesystem { return path.join(...args); } + /** + * @param dir + * @returns {import('fs').Dirent|null} null when the directory has no files + */ static newest(dir) { let files = Filesystem.readDir(dir); + if (files.length === 0) return null; + 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); + return files.reduce((newest, file) => (ctime(file) > ctime(newest) ? file : newest), files[0]); } static all(dir) { - let ctime = file => fs.statSync(path.join(dir, file.name)).ctime; + let ctime = file => fs.statSync(path.join(dir, file.name)).ctime.getTime(); return Filesystem.readDir(dir).sort((a, b) => ctime(a) - ctime(b)); } diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index 1ab4ea5..00250dc 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -11,7 +11,10 @@ class GitlabClient { static init(config) { if(GitlabClient.throttle == undefined){ - GitlabClient.throttle = throttledQueue(config.data.throttleMaxRequestsPerInterval, config.data.throttleInterval); + GitlabClient.throttle = throttledQueue({ + maxPerInterval: config.data.throttleMaxRequestsPerInterval, + interval: config.data.throttleInterval + }); } } @@ -184,7 +187,7 @@ class GitlabClient { * @param perPage * @returns {Array} */ - static createGetTasks(path, to, from = 2, perPage = this._perPage) { + static createGetTasks(path, to, from = 2, perPage) { let tasks = []; for (let i = from; i <= to; i++) { diff --git a/src/core/global.d.ts b/src/core/global.d.ts new file mode 100644 index 0000000..66a5455 --- /dev/null +++ b/src/core/global.d.ts @@ -0,0 +1,8 @@ +export {}; + +// Number.prototype.padLeft is monkeypatched onto the global Number in time.js +declare global { + interface Number { + padLeft(n: number, str?: string): string; + } +} diff --git a/src/core/owner.js b/src/core/owner.js index 761aaf7..bfa21dc 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -15,7 +15,7 @@ class Owner { /** * is authorized? - * @returns {Promise} + * @returns {Promise} */ authorized() { if (!this.config.get('_checkToken')) return new Promise(r => r()); @@ -33,7 +33,7 @@ class Owner { /** * query and set the group * @param fullPath group path, e.g. "group/subgroup" - * @returns {Promise} + * @returns {Promise} */ getGroup(fullPath = this.config.get('project')) { return new Promise((resolve, reject) => { @@ -53,7 +53,7 @@ class Owner { /** * get sub groups - * @returns {Promise} + * @returns {Promise} */ getSubGroups() { return new Promise((resolve, reject) => { diff --git a/src/core/task.js b/src/core/task.js index 090b33d..930c6e3 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -4,6 +4,9 @@ import Time from './time.js'; import chargeRatio from './billing.js'; class Task { + /** @type {string|undefined} set by Report.process() after fetching the owning project */ + project_namespace; + constructor(config, data = {}, client = new GitlabClient(config), type) { this.config = config; this.client = client; diff --git a/src/core/time.js b/src/core/time.js index fc71f1b..030a10c 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -16,11 +16,14 @@ Number.prototype.padLeft = function (n, str) { * time model */ class Time { + /** @type {string|undefined} set by Task.recordTimelogs from the parent task */ + project_namespace; + /** * construct * @param timeString * @param note - * @param {hasTimes} parent + * @param {import('./task.js').default} parent * @param config */ constructor(timeString, date = null, note, parent, config) { @@ -137,18 +140,18 @@ class Time { inserts.days_overall = input / secondsInADay; inserts.days_overall_comma = inserts.days_overall.toString().replace('.', ','); inserts.days = Math.floor(inserts.days_overall); - inserts.Days = inserts.days.padLeft(2, 0); + inserts.Days = inserts.days.padLeft(2, '0'); inserts.hours_overall = input / secondsInAnHour; inserts.hours_overall_comma = inserts.hours_overall.toString().replace('.', ','); inserts.hours = Math.floor((input % secondsInADay) / secondsInAnHour); - inserts.Hours = inserts.hours.padLeft(2, 0); + inserts.Hours = inserts.hours.padLeft(2, '0'); inserts.minutes_overall = input / secondsInAMinute; inserts.minutes_overall_comma = (inserts.minutes_overall).toString().replace('.', ','); inserts.minutes = Math.floor(((input % secondsInADay) % secondsInAnHour) / secondsInAMinute); - inserts.Minutes = inserts.minutes.padLeft(2, 0); + inserts.Minutes = inserts.minutes.padLeft(2, '0'); inserts.seconds_overall = input; inserts.seconds = ((input % secondsInADay) % secondsInAnHour) % secondsInAMinute; - inserts.Seconds = inserts.seconds.padLeft(2, 0); + inserts.Seconds = inserts.seconds.padLeft(2, '0'); // rounded while ((match = roundedRegex.exec(format)) !== null) { diff --git a/src/reporting/api/project.js b/src/reporting/api/project.js index e09ec93..fea8fbe 100755 --- a/src/reporting/api/project.js +++ b/src/reporting/api/project.js @@ -30,14 +30,14 @@ class Project { /** * set members - * @returns {Promise} + * @returns {Promise} */ members() { return new Promise((resolve, reject) => { this.client.get(`projects/${this.id}/members`) .then(response => { this.projectMembers = this.projectMembers.concat(response.body); - return new Promise(r => r()); + return Promise.resolve(); }) .then(() => { if (!this.data.namespace || !this.data.namespace.kind || this.data.namespace.kind !== "group") return resolve(); diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index d60471a..ca5c9b2 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -125,7 +125,7 @@ class Report { * process the given input * @param input * @param model - * @param advance + * @param {(() => void)|false} [advance] * @returns {*|Promise} */ process(input, model, advance = false) { @@ -164,7 +164,7 @@ class Report { /** * process issues - * @param advance + * @param {(() => void)|false} [advance] * @returns {Promise} */ processIssues(advance = false) { @@ -173,7 +173,7 @@ class Report { /** * process merge requests - * @param advance + * @param {(() => void)|false} [advance] * @return {Promise} */ processMergeRequests(advance = false) { diff --git a/src/reporting/output/csv.js b/src/reporting/output/csv.js index 2054b88..dcb925a 100755 --- a/src/reporting/output/csv.js +++ b/src/reporting/output/csv.js @@ -15,7 +15,7 @@ class CsvOutput extends Output { stats[1].push(time); }); - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { Object.entries(this.projects).forEach(([name, time]) => { stats[0].push(name); stats[1].push(time); diff --git a/src/reporting/output/invoice.js b/src/reporting/output/invoice.js index df9d36f..6a68b3c 100644 --- a/src/reporting/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -81,7 +81,7 @@ class InvoiceOutput extends Output { Object.entries(this.stats).forEach(([name, time]) => stats += `\n* **${name}**: ${time}`); stats += `\n`; - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${pc.red(name)}**: ${time}`); stats += `\n`; } diff --git a/src/reporting/output/markdown.js b/src/reporting/output/markdown.js index 549142f..8f6437e 100755 --- a/src/reporting/output/markdown.js +++ b/src/reporting/output/markdown.js @@ -24,7 +24,7 @@ class MarkdownOutput extends Output { Object.entries(this.stats).forEach(([name, time]) => stats += `\n* **${name}**: ${time}`); stats += `\n`; - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${pc.red(name)}**: ${time}`); stats += `\n`; } diff --git a/src/reporting/output/table.js b/src/reporting/output/table.js index 6d24e1b..3636a23 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -25,7 +25,7 @@ class TableOutput extends Output { Object.entries(this.stats).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); stats += `\n`; - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { Object.entries(this.projects).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); stats += `\n`; } diff --git a/src/timekeeping/commands/delete.js b/src/timekeeping/commands/delete.js index bdf3a6a..a751b79 100755 --- a/src/timekeeping/commands/delete.js +++ b/src/timekeeping/commands/delete.js @@ -12,8 +12,11 @@ function delCmd(configLoader) { let config = configLoader(); - if (!id && -Infinity === (id = Fs.newest(config.frameDir).name)) - Cli.error('No record found.'); + if (!id) { + let newest = Fs.newest(config.frameDir); + if (!newest) Cli.error('No record found.'); + id = newest.name; + } let file = Fs.join(config.frameDir, id.replace('.json', '') + '.json'); if (!Fs.exists(file)) { diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index f463d17..11a8730 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -218,7 +218,7 @@ function edit(configLoader) { .description('edit time record by the given id') .arguments('[id]') .option('-f, --following ', 'edit also the following (by ctime) of the given [id]') - .option('-n, --listsize ', 'list size', 30) + .option('-n, --listsize ', 'list size', '30') .option('-i, --interactive', 'edit start/stop time interactively with keystrokes') .option('--today', 'only list entries for today') .option('--this_week', 'only list entries for this week') diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index 19636cc..4c5a031 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -38,8 +38,8 @@ function resume(configLoader) { if (!config.get('project')) Cli.error('No project set'); - let lastFrames = Fs.all(config.frameDir).slice(-listSize); // last listSize frames (one page of inquirer) - lastFrames = lastFrames.map((file) => + let frameFiles = Fs.all(config.frameDir).slice(-listSize); // last listSize frames (one page of inquirer) + let lastFrames = frameFiles.map((file) => Frame.fromFile(config, Fs.join(config.frameDir, file.name)) ); lastFrames = lastFrames.sort((a, b) => dayjs(a.stop || dayjs()).isBefore(dayjs(b.stop || dayjs())) ? 1 : -1); diff --git a/tsconfig.json b/tsconfig.json index bed6d74..1759e6f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "strict": false, "types": ["node"] }, - "include": ["src/**/*.js"] + "include": ["src/**/*.js", "src/**/*.d.ts"] } From 36d8039d151058d7444e4315db227caf692e616d Mon Sep 17 00:00:00 2001 From: ndu Date: Fri, 10 Jul 2026 23:58:54 +0200 Subject: [PATCH 65/72] remove mutation after construction of time object --- src/core/issue.js | 4 ++-- src/core/mergeRequest.js | 4 ++-- src/core/task.js | 19 ++++++++++--------- src/core/time.js | 24 +++++++++++++++--------- src/reporting/api/report.js | 3 +-- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/core/issue.js b/src/core/issue.js index fdcd227..27b6112 100644 --- a/src/core/issue.js +++ b/src/core/issue.js @@ -4,8 +4,8 @@ import GitlabClient from './gitlab-client.js'; class Issue extends CoreTask { static resoureType = 'issues'; - constructor(config, data, client) { - super(config, data, client, Issue.resoureType); + constructor(config, data, client, project_namespace) { + super(config, data, client, Issue.resoureType, project_namespace); } static list(config, project, state, my, client = new GitlabClient(config)) { diff --git a/src/core/mergeRequest.js b/src/core/mergeRequest.js index 34f158a..b730140 100644 --- a/src/core/mergeRequest.js +++ b/src/core/mergeRequest.js @@ -4,8 +4,8 @@ import GitlabClient from './gitlab-client.js'; class MergeRequest extends CoreTask { static resoureType = 'merge_requests'; - constructor(config, data, client) { - super(config, data, client, MergeRequest.resoureType); + constructor(config, data, client, project_namespace) { + super(config, data, client, MergeRequest.resoureType, project_namespace); } static list(config, project, state, my, client = new GitlabClient(config)) { diff --git a/src/core/task.js b/src/core/task.js index 930c6e3..12d99cc 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -4,15 +4,20 @@ import Time from './time.js'; import chargeRatio from './billing.js'; class Task { - /** @type {string|undefined} set by Report.process() after fetching the owning project */ - project_namespace; - - constructor(config, data = {}, client = new GitlabClient(config), type) { + /** + * @param config + * @param data + * @param client + * @param type + * @param {string} [project_namespace] the owning project's path_with_namespace, if known + */ + constructor(config, data = {}, client = new GitlabClient(config), type, project_namespace) { this.config = config; this.client = client; this.times = []; this.data = data; this.type = type; + this.project_namespace = project_namespace; } get iid() { @@ -176,11 +181,7 @@ class Task { author: {username: timelog.user.username}, created_at: timelog.spentAt, noteable_type: this._typeSingular - }, this, this.config); - time.seconds = timelog.timeSpent; - time.project_namespace = this.project_namespace; - time.note = timelog.note && timelog.note.body ? timelog.note.body : null; - time.chargeRatio = ratio; + }, this, this.config, timelog.timeSpent, timelog.note && timelog.note.body ? timelog.note.body : null, ratio); // only include times by the configured user if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; diff --git a/src/core/time.js b/src/core/time.js index 030a10c..8fcc745 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -16,25 +16,27 @@ Number.prototype.padLeft = function (n, str) { * time model */ class Time { - /** @type {string|undefined} set by Task.recordTimelogs from the parent task */ - project_namespace; - /** * construct - * @param timeString - * @param note + * @param timeString parsed into seconds if given; otherwise seconds is used as-is + * @param date + * @param data raw noteable payload (author, created_at, noteable_type) * @param {import('./task.js').default} parent * @param config + * @param {number} [seconds] used when timeString is falsy + * @param {string|null} [note] + * @param {number} [chargeRatio] */ - constructor(timeString, date = null, note, parent, config) { - this.data = note; + constructor(timeString, date = null, data, parent, config, seconds, note = null, chargeRatio = 1.0) { + this.data = data; this._date = date; this.parent = parent; this.config = config; - this.note = null; - this.chargeRatio = 1.0; + this.note = note; + this.chargeRatio = chargeRatio; if(!timeString) { + this.seconds = seconds; return; } @@ -68,6 +70,10 @@ class Time { return this.parent.iid; } + get project_namespace() { + return this.parent.project_namespace; + } + get time() { return Time.toHumanReadable(this.seconds, this._hoursPerDay, this._timeFormat); } diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index ca5c9b2..646e65a 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -132,8 +132,7 @@ class Report { let collect = []; let promise = parallel(this[input], async data => { - let item = new model(this.config, data, this.client); - item.project_namespace = this.projects[item.project_id]; + let item = new model(this.config, data, this.client, this.projects[data.project_id]); item.recordTimelogs(timelogsFor(this.timelogs, input, data)); From d1f9459af869c5db6bc90940d553d99d57b9eea2 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 11 Jul 2026 21:55:33 +0200 Subject: [PATCH 66/72] clean time class --- spec/models/time.title.spec.js | 36 ---------------------------------- spec/output/base.spec.js | 36 ++++++++++++++++++++++++++++------ src/core/task.js | 2 +- src/core/time.js | 34 +++----------------------------- src/reporting/output/base.js | 16 ++++++++------- src/reporting/stats.js | 14 ++++++------- 6 files changed, 50 insertions(+), 88 deletions(-) delete mode 100644 spec/models/time.title.spec.js diff --git a/spec/models/time.title.spec.js b/spec/models/time.title.spec.js deleted file mode 100644 index 62c31e1..0000000 --- a/spec/models/time.title.spec.js +++ /dev/null @@ -1,36 +0,0 @@ - -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'; - -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', dayjs(), {}, parent, config); - - expect(time.title).to.be.equal("Test title"); - }); - - 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', dayjs(), {}, parent, config); - - expect(time.title).to.be.equal("Test title"); - }); - - it('Returns Null for missed title or parent', () => { - const config = new Config(process.cwd()); - const parent = new task(config, {}, undefined, 'merge_requests'); - let time; - time = new Time('1h', dayjs(), {}, parent, config); - expect(time.title).to.be.equal(null); - - 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 index d796919..c810e58 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -4,20 +4,26 @@ 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', note = null, chargeRatio = 1.0 } = {}) { - return { user, seconds, iid, project_namespace: project, date: dayjs(date), note, chargeRatio }; +function makeTime({ user = 'alice', seconds = 0, date = '2026-01-05T10:00:00Z', note = null, chargeRatio = 1.0 } = {}) { + return { user, seconds, date: dayjs(date), note, chargeRatio }; } -function makeIssue({ iid = 1, labels = [], times = [], estimate = 0, spent = 0, project_id = 1, title = 'issue' } = {}) { - return { +function makeIssue({ iid = 1, labels = [], times = [], estimate = 0, spent = 0, project_id = 1, title = 'issue', project_namespace = 'group/project' } = {}) { + const issue = { iid, project_id, + project_namespace, title, labels, times, total_spent_s : spent, total_estimate_s : estimate }; + + // Time delegates iid/title/project_namespace to its parent Task + times.forEach(time => { time.parent = issue; }); + + return issue; } describe('calculateStats', () => { @@ -40,8 +46,8 @@ describe('calculateStats', () => { ] }), makeIssue({ - iid: 2, times: [ - makeTime({ user: 'alice', seconds: 1800, project: 'group/other' }) + iid: 2, project_namespace: 'group/other', times: [ + makeTime({ user: 'alice', seconds: 1800 }) ] }) ]); @@ -134,4 +140,22 @@ describe('Output.prepare', () => { expect(row).to.deep.equal([7, '2026-01-05', '', '', 'a,b']); }); + + it('falls back to obj.parent for a column missing on obj itself - e.g. a Time record deferring to its parent Task', () => { + const config = new Config(); + const output = new Output(config, { issues: [], mergeRequests: [] }); + const parent = { iid: 42, title: 'Parent title' }; + + const row = output.prepare({ parent, seconds: 60 }, ['iid', 'title', 'seconds']); + + expect(row).to.deep.equal([42, 'Parent title', 60]); + }); + + it('returns empty string when the column is missing on both obj and its parent, or there is no parent', () => { + const config = new Config(); + const output = new Output(config, { issues: [], mergeRequests: [] }); + + expect(output.prepare({ parent: {} }, ['title'])).to.deep.equal(['']); + expect(output.prepare({}, ['title'])).to.deep.equal(['']); + }); }); diff --git a/src/core/task.js b/src/core/task.js index 12d99cc..dca9808 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -177,7 +177,7 @@ class Task { (timelog) => { let spentAt = dayjs(timelog.spentAt); - let time = new Time(null, spentAt, { + let time = new Time(spentAt, { author: {username: timelog.user.username}, created_at: timelog.spentAt, noteable_type: this._typeSingular diff --git a/src/core/time.js b/src/core/time.js index 8fcc745..e8165ff 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -18,29 +18,22 @@ Number.prototype.padLeft = function (n, str) { class Time { /** * construct - * @param timeString parsed into seconds if given; otherwise seconds is used as-is * @param date * @param data raw noteable payload (author, created_at, noteable_type) * @param {import('./task.js').default} parent * @param config - * @param {number} [seconds] used when timeString is falsy + * @param {number} seconds * @param {string|null} [note] * @param {number} [chargeRatio] */ - constructor(timeString, date = null, data, parent, config, seconds, note = null, chargeRatio = 1.0) { + constructor(date = null, data, parent, config, seconds, note = null, chargeRatio = 1.0) { this.data = data; this._date = date; this.parent = parent; this.config = config; this.note = note; this.chargeRatio = chargeRatio; - - if(!timeString) { - this.seconds = seconds; - return; - } - - this.seconds = Time.parse(timeString, this._hoursPerDay, this._daysPerWeek, this._weeksPerMonth); + this.seconds = seconds; } /* @@ -62,31 +55,10 @@ class Time { return this.data.noteable_type; } - get project_id() { - return this.parent.data.project_id; - } - - get iid() { - return this.parent.iid; - } - - get project_namespace() { - return this.parent.project_namespace; - } - get time() { return Time.toHumanReadable(this.seconds, this._hoursPerDay, this._timeFormat); } - /** - * Title of the linked Noteable object (Issue/MergeRequest) - * - * @returns {String|null} - */ - get title() { - return this.parent && this.parent.title || null; - } - get _timeFormat() { return this.config && this.config.get('timeFormat', 'records') ? this.config.get('timeFormat', 'records') : ''; } diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index 0605bd4..e478f5a 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -125,23 +125,25 @@ class Output { /** * prepare the given object by only returning * the given columns/properties and formatting - * special properties like dayjs instances + * special properties like dayjs instances. * @param obj * @param columns * @returns {Array} */ prepare(obj = {}, columns = []) { return columns.map(column => { - if (dayjs.isDayjs(obj[column])) - return obj[column].format(this.config.get('dateFormat')); + let value = obj[column]; - if (obj[column] === undefined || obj[column] === null) + if (dayjs.isDayjs(value)) + return value.format(this.config.get('dateFormat')); + + if (value === undefined || value === null) return ''; - if(typeof obj[column] == 'object') { - return obj[column].toString() + if(typeof value == 'object') { + return value.toString() } - return obj[column]; + return value; }); } } diff --git a/src/reporting/stats.js b/src/reporting/stats.js index 8325aba..0cd89df 100644 --- a/src/reporting/stats.js +++ b/src/reporting/stats.js @@ -47,21 +47,21 @@ export default function calculateStats(config, report) { issueDays[dateGrp].addSpent(time.seconds); if (!users[time.user]) users[time.user] = 0; - if (!projects[time.project_namespace]) projects[time.project_namespace] = 0; + if (!projects[issue.project_namespace]) projects[issue.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][issue.project_namespace]) { + days[dateGrp][issue.project_namespace] = {}; } - if (!days[dateGrp][time.project_namespace][time.iid]) { - days[dateGrp][time.project_namespace][time.iid] = 0; + if (!days[dateGrp][issue.project_namespace][issue.iid]) { + days[dateGrp][issue.project_namespace][issue.iid] = 0; } users[time.user] += time.seconds; - projects[time.project_namespace] += time.seconds; - days[dateGrp][time.project_namespace][time.iid] += time.seconds; + projects[issue.project_namespace] += time.seconds; + days[dateGrp][issue.project_namespace][issue.iid] += time.seconds; spent += time.seconds; From b6db190cf341d26dfc976a5a4c5391b7896ff815 Mon Sep 17 00:00:00 2001 From: ndu Date: Sat, 11 Jul 2026 23:03:40 +0200 Subject: [PATCH 67/72] cleanup reporting --- documentation.md | 13 +- package.json | 4 +- spec/output/base.spec.js | 13 -- src/core/issue.js | 4 +- src/core/mergeRequest.js | 4 +- src/core/owner.js | 33 ----- src/core/task.js | 17 ++- src/core/time.js | 4 +- src/reporting/api/masterReport.js | 91 +++++++++++++ src/reporting/api/project.js | 22 ++-- src/reporting/api/projectReport.js | 89 +++++++++++++ src/reporting/api/report.js | 183 -------------------------- src/reporting/api/reportCollection.js | 7 +- src/reporting/commands/report.js | 15 ++- src/reporting/reportConfigBuilder.js | 4 + src/reporting/reportRunner.js | 29 ++-- src/timekeeping/commands/edit.js | 7 +- 17 files changed, 261 insertions(+), 278 deletions(-) create mode 100644 src/reporting/api/masterReport.js create mode 100644 src/reporting/api/projectReport.js delete mode 100755 src/reporting/api/report.js diff --git a/documentation.md b/documentation.md index 856a5f1..ea32673 100644 --- a/documentation.md +++ b/documentation.md @@ -202,7 +202,9 @@ You can omit the id to edit to bring up a list of the latest records to choose f ```shell gtt edit -i +gtt edit -i --today gtt edit -i --this_week +gtt edit -i --last_week gtt edit -i --week 2026-02-04 ``` @@ -327,6 +329,8 @@ There are some quick shorthands: gtt report --today gtt report --this_week gtt report --this_month +gtt report --last_week +gtt report --last_month_ ``` #### Include closed issues/merge requests @@ -428,9 +432,10 @@ gtt report --record_columns=user --record_columns=date --record_columns=time gtt report --issue_columns=iid --issue_columns=title --issue_columns=time_username ``` -*Note: Available columns to choose from: `id`, `iid`, `title`, `project_id`, -`project_namespace`, `description`, `labels`, `milestone`, `assignee`, `author`, -`closed`, `updated_at`, `created_at`, `due_date`, `state`, `spent`, `total_spent`, `total_estimate`* +*Note: Available columns to choose from: `id`, `iid`, `title`, `project_id`, `type`, +`project_namespace`, `project_name`, `description`, `labels`, `milestone`, `assignee`, `author`, +`closed`, `updated_at`, `created_at`, `due_date`, `state`, `spent`, `total_spent`, `total_estimate`, +`time_`, `timeSpent`. `total_estimate_s`, `total_spent_s`* *You can also include columns that show the total time spent by a specific user by following this convention: `time_username`* @@ -558,6 +563,7 @@ weeksPerMonth: 4 issueColumns: - iid - title +- spent - total_estimate # Include the given columns in the merge request table @@ -566,6 +572,7 @@ issueColumns: mergeRequestColumns: - iid - title +- spent - total_estimate # Include the given columns in the time record table diff --git a/package.json b/package.json index c5ce7d1..700d169 100755 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "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", - "buildAll": "npm run-script prebuild && npm run-script test && npm run-script esbuild && npm run-script pkg && npm run-script docker" + "build": "npm run-script prebuild && npm run-script typecheck && npm run-script test && npm run-script esbuild && npm run-script pkg", + "buildAll": "npm run-script prebuild && npm run-script typecheck && npm run-script test && npm run-script esbuild && npm run-script pkg && npm run-script docker" }, "bin": { "gtt": "dist/gtt.cjs" diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js index c810e58..2605c0a 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -20,9 +20,6 @@ function makeIssue({ iid = 1, labels = [], times = [], estimate = 0, spent = 0, total_estimate_s : estimate }; - // Time delegates iid/title/project_namespace to its parent Task - times.forEach(time => { time.parent = issue; }); - return issue; } @@ -141,16 +138,6 @@ describe('Output.prepare', () => { expect(row).to.deep.equal([7, '2026-01-05', '', '', 'a,b']); }); - it('falls back to obj.parent for a column missing on obj itself - e.g. a Time record deferring to its parent Task', () => { - const config = new Config(); - const output = new Output(config, { issues: [], mergeRequests: [] }); - const parent = { iid: 42, title: 'Parent title' }; - - const row = output.prepare({ parent, seconds: 60 }, ['iid', 'title', 'seconds']); - - expect(row).to.deep.equal([42, 'Parent title', 60]); - }); - it('returns empty string when the column is missing on both obj and its parent, or there is no parent', () => { const config = new Config(); const output = new Output(config, { issues: [], mergeRequests: [] }); diff --git a/src/core/issue.js b/src/core/issue.js index 27b6112..aca2b00 100644 --- a/src/core/issue.js +++ b/src/core/issue.js @@ -4,8 +4,8 @@ import GitlabClient from './gitlab-client.js'; class Issue extends CoreTask { static resoureType = 'issues'; - constructor(config, data, client, project_namespace) { - super(config, data, client, Issue.resoureType, project_namespace); + constructor(config, data, client, project) { + super(config, data, client, Issue.resoureType, project); } static list(config, project, state, my, client = new GitlabClient(config)) { diff --git a/src/core/mergeRequest.js b/src/core/mergeRequest.js index b730140..8e958a2 100644 --- a/src/core/mergeRequest.js +++ b/src/core/mergeRequest.js @@ -4,8 +4,8 @@ import GitlabClient from './gitlab-client.js'; class MergeRequest extends CoreTask { static resoureType = 'merge_requests'; - constructor(config, data, client, project_namespace) { - super(config, data, client, MergeRequest.resoureType, project_namespace); + constructor(config, data, client, project) { + super(config, data, client, MergeRequest.resoureType, project); } static list(config, project, state, my, client = new GitlabClient(config)) { diff --git a/src/core/owner.js b/src/core/owner.js index bfa21dc..eb3b53e 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -83,39 +83,6 @@ class Owner { return filtered; } - // /** - // * query and set the user - // * @returns {Promise} - // */ - // getUser() { - // return new Promise((resolve, reject) => { - // this.get(`users/?username=${encodeURIComponent(this.config.get('project'))}`) - // .then(user => { - // if (user.body.length === 0) return reject(); - // let filtered = user.body.filter(u => u.username === this.config.get('project')); - // if (filtered.length === 0) return reject(); - // this.user = filtered[0]; - // resolve(); - // }) - // .catch(e => reject(e)); - // }); - // } - // - // /** - // * query and set the projects by a user - // * @returns {Promise} - // */ - // getProjectsByUser() { - // return new Promise((resolve, reject) => { - // this.get(`users/${this.user.id}/projects`) - // .then(projects => { - // this.projects = this.projects.concat(projects.body); - // resolve(); - // }) - // .catch(e => reject(e)); - // }); - // } - /** * query and set the projects by a user * @returns {Promise} diff --git a/src/core/task.js b/src/core/task.js index dca9808..4b8aee5 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -2,6 +2,7 @@ import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; import Time from './time.js'; import chargeRatio from './billing.js'; +import Project from '../reporting/api/project.js'; class Task { /** @@ -9,15 +10,15 @@ class Task { * @param data * @param client * @param type - * @param {string} [project_namespace] the owning project's path_with_namespace, if known + * @param {Project} [project] the owning project's, if known */ - constructor(config, data = {}, client = new GitlabClient(config), type, project_namespace) { + constructor(config, data = {}, client = new GitlabClient(config), type, project) { this.config = config; this.client = client; this.times = []; this.data = data; this.type = type; - this.project_namespace = project_namespace; + this.project = project; } get iid() { @@ -36,6 +37,14 @@ class Task { return this.data.project_id; } + get project_namespace() { + return this.project.namespace; + } + + get project_name() { + return this.project.name; + } + get description() { return this.data.description; } @@ -181,7 +190,7 @@ class Task { author: {username: timelog.user.username}, created_at: timelog.spentAt, noteable_type: this._typeSingular - }, this, this.config, timelog.timeSpent, timelog.note && timelog.note.body ? timelog.note.body : null, ratio); + }, this.config, timelog.timeSpent, timelog.note && timelog.note.body ? timelog.note.body : null, ratio); // only include times by the configured user if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; diff --git a/src/core/time.js b/src/core/time.js index e8165ff..2cde9d7 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -20,16 +20,14 @@ class Time { * construct * @param date * @param data raw noteable payload (author, created_at, noteable_type) - * @param {import('./task.js').default} parent * @param config * @param {number} seconds * @param {string|null} [note] * @param {number} [chargeRatio] */ - constructor(date = null, data, parent, config, seconds, note = null, chargeRatio = 1.0) { + constructor(date = null, data, config, seconds, note = null, chargeRatio = 1.0) { this.data = data; this._date = date; - this.parent = parent; this.config = config; this.note = note; this.chargeRatio = chargeRatio; diff --git a/src/reporting/api/masterReport.js b/src/reporting/api/masterReport.js new file mode 100644 index 0000000..4546f3d --- /dev/null +++ b/src/reporting/api/masterReport.js @@ -0,0 +1,91 @@ +import GitlabClient from '../../core/gitlab-client.js'; +import parallel from '../../core/parallel.js'; +import Issue from '../../core/issue.js'; +import MergeRequest from '../../core/mergeRequest.js'; +import {timelogsFor} from './timelogs.js'; + +/** + * aggregates ProjectReports and processes their issues/merge requests + */ +class MasterReport { + /** + * constructor. + * @param config + * @param client + */ + constructor(config, client = new GitlabClient(config)) { + this.config = config; + this.client = client; + + this.projects = {}; + this.issues = []; + this.mergeRequests = []; + this.timelogs = []; + } + + /** + * filter empty + * @param issues + * @returns {Array} + */ + filter(issues) { + return issues.filter(issue => this.config.get('showWithoutTimes') || (issue.times && issue.times.length > 0)); + } + + /** + * process the given input + * @param input + * @param model + * @param {(() => void)|false} [advance] + * @returns {*|Promise} + */ + process(input, model, advance = false) { + let collect = []; + + let promise = parallel(this[input], async data => { + let item = new model(this.config, data, this.client, this.projects[data.project_id]); + + item.recordTimelogs(timelogsFor(this.timelogs, input, data)); + + if (this.config.get('showWithoutTimes') || item.times.length > 0) { + collect.push(item); + } + + if (advance) advance(); + }, this.config); + + promise.then(() => this[input] = this.filter(collect)); + return promise; + } + + /** + * merge a project report into this report + * @param {import('./projectReport.js').default} report + */ + merge(report) { + this.issues = this.issues.concat(report.issues); + this.mergeRequests = this.mergeRequests.concat(report.mergeRequests); + this.projects[report.project.id] = report.project; + this.timelogs = this.timelogs.concat(report.timelogs); + } + + /** + * process issues + * @param {(() => void)|false} [advance] + * @returns {Promise} + */ + processIssues(advance = false) { + return this.process('issues', Issue, advance); + } + + /** + * process merge requests + * @param {(() => void)|false} [advance] + * @return {Promise} + */ + processMergeRequests(advance = false) { + return this.process('mergeRequests', MergeRequest, advance); + } +} + +export default MasterReport; diff --git a/src/reporting/api/project.js b/src/reporting/api/project.js index fea8fbe..f3530a1 100755 --- a/src/reporting/api/project.js +++ b/src/reporting/api/project.js @@ -6,9 +6,9 @@ import GitlabClient from '../../core/gitlab-client.js'; class Project { /** * construct - * @param config + * @param {import('../../core/config.js').default} config * @param data - * @param client + * @param {GitlabClient} client */ constructor(config, data, client = new GitlabClient(config)) { this.config = config; @@ -19,13 +19,13 @@ class Project { /** * make - * @param name + * @param {import('../../core/config.js').default} config + * @param {GitlabClient} client + * @param {string} name */ - make(name) { - let promise = this.client.get(`projects/${encodeURIComponent(name)}`); - promise.then(project => this.data = project.body); - - return promise; + static async create(config, name, client) { + let data = await client.get(`projects/${encodeURIComponent(name)}`); + return new Project(config, data.body, client); } /** @@ -60,10 +60,14 @@ class Project { return this.data.id; } - get name() { + get namespace() { return this.data.path_with_namespace; } + get name() { + return this.data.name; + } + get users() { return this.projectMembers.map(member => member.username); } diff --git a/src/reporting/api/projectReport.js b/src/reporting/api/projectReport.js new file mode 100644 index 0000000..d88a692 --- /dev/null +++ b/src/reporting/api/projectReport.js @@ -0,0 +1,89 @@ +import GitlabClient from '../../core/gitlab-client.js'; +import Project from './project.js'; +import fetchTimelogs from './timelogs.js'; +import {excludeByLabel, excludeMoved} from './filters.js'; +import Config from '../../core/config.js'; + +/** + * fetches issues, merge requests and timelogs for a single project + */ +class ProjectReport { + /** + * constructor. + * @param {Config} config + * @param {Project} project + * @param {GitlabClient} client + */ + constructor(config, project, client = new GitlabClient(config)) { + this.config = config; + this.client = client; + this.project = project; + + this.issues = []; + this.mergeRequests = []; + + this.timelogs = null; + } + + /** + * get params for querying issues and merge requests + * @returns {string} + */ + params() { + let params = []; + + if (this.config.get('iids') && this.config.get('query').length === 1) { + params.push(`iids=${this.config.get('iids').join(',')}`) + } + + if (!this.config.get('closed')) { + params.push(`state=opened`); + } + + if (this.config.get('includeByLabels')) { + params.push(`labels=${this.config.get('includeByLabels').join(',')}`); + } + + if (this.config.get('milestone')) { + params.push(`milestone=${this.config.get('milestone')}`); + } + + return `?${params.join('&')}`; + } + + /** + * query and set merge requests + * @returns {Promise} + */ + getMergeRequests() { + let promise = this.client.all(`projects/${this.project.id}/merge_requests${this.params()}`); + let excludes = this.config.get('excludeByLabels'); + promise.then(mergeRequests => this.mergeRequests = excludeByLabel(mergeRequests, excludes)); + + return promise; + } + + /** + * query and set issues + * @returns {Promise} + */ + getIssues() { + let promise = this.client.all(`projects/${this.project.id}/issues${this.params()}`); + let excludes = this.config.get('excludeByLabels'); + promise.then(issues => this.issues = excludeByLabel(excludeMoved(issues), excludes)); + + return promise; + } + + /** + * fetch every timelog for this project in the configured date range. + * @returns {Promise} + */ + async getTimelogs() { + this.timelogs = await fetchTimelogs(this.client, this.project.data.path_with_namespace, this.config.get('from'), this.config.get('to')); + + return this.timelogs; + } +} + +export default ProjectReport; diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js deleted file mode 100755 index 646e65a..0000000 --- a/src/reporting/api/report.js +++ /dev/null @@ -1,183 +0,0 @@ -import dayjs from '../../core/dayjs.js'; -import GitlabClient from '../../core/gitlab-client.js'; -import parallel from '../../core/parallel.js'; -import Issue from '../../core/issue.js'; -import MergeRequest from '../../core/mergeRequest.js'; -import Project from './project.js'; -import fetchTimelogs, {timelogsFor} from './timelogs.js'; -import {excludeByLabel, excludeMoved} from './filters.js'; - -/** - * report model - */ -class Report { - /** - * constructor. - * @param config - * @param project - */ - constructor(config, project, client = new GitlabClient(config)) { - this.config = config; - this.client = client; - - this.projects = {}; - this.setProject(project); - - this.issues = []; - this.mergeRequests = []; - - this.timelogs = null; - } - - /** - * get params for querying issues and merge requests - * @returns {string} - */ - params() { - let params = []; - - if (this.config.get('iids') && this.config.get('query').length === 1) { - params.push(`iids=${this.config.get('iids').join(',')}`) - } - - if (!this.config.get('closed')) { - params.push(`state=opened`); - } - - if (this.config.get('includeByLabels')) { - params.push(`labels=${this.config.get('includeByLabels').join(',')}`); - } - - if (this.config.get('milestone')) { - params.push(`milestone=${this.config.get('milestone')}`); - } - - return `?${params.join('&')}`; - } - - /** - * set the project by the given data - * @param project - */ - setProject(project) { - if (!project) return; - - this.projects[project.id] = project.path_with_namespace; - this.project = new Project(this.config, project, this.client) - } - - /** - * query and set the project - * @param namespace project path, e.g. "group/project" - * @returns {Promise} - */ - getProject(namespace = this.config.get('project')) { - let promise = this.client.get(`projects/${encodeURIComponent(namespace)}`); - promise.then(project => this.setProject(project.body)); - - return promise; - } - - /** - * query and set merge requests - * @returns {Promise} - */ - getMergeRequests() { - let promise = this.client.all(`projects/${this.project.id}/merge_requests${this.params()}`); - let excludes = this.config.get('excludeByLabels'); - promise.then(mergeRequests => this.mergeRequests = excludeByLabel(mergeRequests, excludes)); - - return promise; - } - - /** - * query and set issues - * @returns {Promise} - */ - getIssues() { - let promise = this.client.all(`projects/${this.project.id}/issues${this.params()}`); - let excludes = this.config.get('excludeByLabels'); - promise.then(issues => this.issues = excludeByLabel(excludeMoved(issues), excludes)); - - return promise; - } - - /** - * filter empty - * @param issues - * @returns {Array} - */ - filter(issues) { - return issues.filter(issue => this.config.get('showWithoutTimes') || (issue.times && issue.times.length > 0)); - } - - /** - * fetch every timelog for this project in the configured date range. - * @returns {Promise} - */ - async getTimelogs() { - this.timelogs = await fetchTimelogs(this.client, this.project.data.path_with_namespace, this.config.get('from'), this.config.get('to')); - - return this.timelogs; - } - - /** - * process the given input - * @param input - * @param model - * @param {(() => void)|false} [advance] - * @returns {*|Promise} - */ - process(input, model, advance = false) { - let collect = []; - - let promise = parallel(this[input], async data => { - let item = new model(this.config, data, this.client, this.projects[data.project_id]); - - item.recordTimelogs(timelogsFor(this.timelogs, input, data)); - - if (this.config.get('showWithoutTimes') || item.times.length > 0) { - collect.push(item); - } - - if (advance) advance(); - }, this.config); - - promise.then(() => this[input] = this.filter(collect)); - return promise; - } - - /** - * merge another report into this report - * @param report - */ - merge(report) { - this.issues = this.issues.concat(report.issues); - this.mergeRequests = this.mergeRequests.concat(report.mergeRequests); - if (!this.members) this.members = []; - this.members = this.members.concat(report.members ? report.members : []); - this.projects = Object.assign(this.projects, report.projects); - if (!this.timelogs) this.timelogs = []; - this.timelogs = this.timelogs.concat(report.timelogs); - } - - /** - * process issues - * @param {(() => void)|false} [advance] - * @returns {Promise} - */ - processIssues(advance = false) { - return this.process('issues', Issue, advance); - } - - /** - * process merge requests - * @param {(() => void)|false} [advance] - * @return {Promise} - */ - processMergeRequests(advance = false) { - return this.process('mergeRequests', MergeRequest, advance); - } -} - -export default Report; \ No newline at end of file diff --git a/src/reporting/api/reportCollection.js b/src/reporting/api/reportCollection.js index 6d13e50..907a5a2 100755 --- a/src/reporting/api/reportCollection.js +++ b/src/reporting/api/reportCollection.js @@ -13,14 +13,11 @@ class ReportCollection { } push(report) { - if (!this.projectNames.includes(report.project.name)) { - this.projectNames.push(report.project.name); + if (!this.projectNames.includes(report.project.namespace)) { + this.projectNames.push(report.project.namespace); this.reports.push(report); } } - get length() { - return this.reports.length; - } } export default ReportCollection; diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index a012557..c6fddad 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -19,7 +19,7 @@ function report(configLoader) { const report = new Command('report') .description('generate a report for the given project and issues') .arguments('[project] [ids...]') - .option('-e --type ', 'specify the query type: project, user, group') + .option('-e --type ', 'specify the query type: project, group') .option('--subgroups', 'include sub groups') .option('--url ', 'URL to GitLabs API') .option('--token ', 'API access token') @@ -27,6 +27,7 @@ function report(configLoader) { .option('-t --to ', 'query times that are equal or smaller than the given date') .option('--today', 'ignores --from and --to and queries entries for today') .option('--this_week', 'ignores --from and --to and queries entries for this week') + .option('--last_week', 'ignores --from and --to and queries entries for last week') .option('--this_month', 'ignores --from and --to and queries entries for this month') .option('--last_month', 'ignores --from and --to and queries entries for last month') .option('-c --closed', 'include closed issues') @@ -86,20 +87,20 @@ if (config.get('file') && fs.existsSync(config.get('file'))) { } } -let client = new GitlabClient(config); -let output = await runReport(config, client, Cli); +try { + let client = new GitlabClient(config); + let output = await runReport(config, client, Cli); -// print report -Cli.list(`${Cli.print} Printing report`); + // 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.x(`could not create report.`, error); } Cli.mark(); diff --git a/src/reporting/reportConfigBuilder.js b/src/reporting/reportConfigBuilder.js index f248387..fa203e7 100644 --- a/src/reporting/reportConfigBuilder.js +++ b/src/reporting/reportConfigBuilder.js @@ -64,6 +64,10 @@ export function buildReportConfig(config, opts, args) { config .set('from', dayjs().startOf('week')) .set('to', dayjs().endOf('week').add(1, 'day').startOf('day')); + if (opts.last_week) + config + .set('from', dayjs().startOf('week').subtract(1, 'week')) + .set('to', dayjs().endOf('week').subtract(1, 'week').add(1, 'day').startOf('day')); if (opts.this_month) config .set('from', dayjs().startOf('month')) diff --git a/src/reporting/reportRunner.js b/src/reporting/reportRunner.js index dab0818..d22124f 100644 --- a/src/reporting/reportRunner.js +++ b/src/reporting/reportRunner.js @@ -1,7 +1,9 @@ import pc from 'picocolors'; import Cli from '../core/cli.js'; import Owner from '../core/owner.js'; -import Report from './api/report.js'; +import Project from './api/project.js'; +import ProjectReport from './api/projectReport.js'; +import MasterReport from './api/masterReport.js'; import ReportCollection from './api/reportCollection.js'; import parallel from '../core/parallel.js'; @@ -14,7 +16,8 @@ export const Output = { invoice: () => import('./output/invoice.js') }; -async function resolveProjects(config, client, owner, reports, reporter) { +async function resolveProjects(config, client, owner, reporter) { + let reports = new ReportCollection(config); let projectLabels = Array.isArray(config.get('project')) ? config.get('project').join('", "') : config.get('project'); let projects = Array.isArray(config.get('project')) ? config.get('project') : [config.get('project')]; @@ -24,19 +27,20 @@ async function resolveProjects(config, client, owner, reports, reporter) { await owner.authorized(); } catch (error) { reporter.x(`Invalid access token!`, error); + throw 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); + let p = await Project.create(config, project, client); + let report = new ProjectReport(config, p, client); + reports.push(report); } catch (error) { reporter.x(`Project not found or no access rights "${projectLabels}".`, error); } - reports.push(report); break; } @@ -44,7 +48,10 @@ async function resolveProjects(config, client, owner, reports, reporter) { 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))); + owner.projects.forEach(p => { + let project = new Project(config, p, client); + reports.push(new ProjectReport(config, project, client)) + }); break; } } @@ -64,8 +71,10 @@ async function resolveProjects(config, client, owner, reports, reporter) { } reporter.mark(); + return reports; } catch (error) { reporter.x(`Could not resolve "${projectLabels}"`, error); + throw error; } } @@ -168,14 +177,12 @@ async function makeOutput(config, master, reporter) { * @returns {Promise} the rendered output, ready for toFile/toStdOut */ export async function runReport(config, client, reporter = Cli) { - let owner = new Owner(config, client), - reports = new ReportCollection(config), - master = new Report(config, undefined, client); - - await resolveProjects(config, client, owner, reports, reporter); + let owner = new Owner(config, client); + let reports = await resolveProjects(config, client, owner, reporter); await fetchIssues(config, reports, reporter); await fetchMergeRequests(config, reports, reporter); await fetchTimelogs(reports, reporter); + let master = new MasterReport(config, client); await mergeReports(reports, master, reporter); await processIssues(config, master, reporter); await processMergeRequests(config, master, reporter); diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 11a8730..a66cdda 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -222,6 +222,7 @@ function edit(configLoader) { .option('-i, --interactive', 'edit start/stop time interactively with keystrokes') .option('--today', 'only list entries for today') .option('--this_week', 'only list entries for this week') + .option('--last_week', 'only list entries for last week') .option('--day ', 'only list entries for this day') .option('--week ', 'only list entries for the week of this day') .action((id, opts ,program) => { @@ -245,9 +246,13 @@ function getMenuFrames() { } else { frames = []; } - } else if (program.opts().today || program.opts().this_week) { + } else if (program.opts().today || program.opts().this_week || program.opts().last_week) { let from = program.opts().today ? dayjs().startOf('day') : dayjs().startOf('week'); let to = program.opts().today ? dayjs().add(1, 'day').startOf('day') : dayjs().endOf('week').add(1, 'day').startOf('day'); + if(program.opts().last_week) { + from = from.subtract(1, 'week'); + to = to.subtract(1, 'week'); + } frames = frames.filter((fr) => !fr.start.isBefore(from) && !fr.start.isAfter(to)); frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)); } else if (program.opts().day || program.opts().week) { From 6abb31d22721e50459528d2d42d9c51b90898426 Mon Sep 17 00:00:00 2001 From: ndu Date: Sun, 12 Jul 2026 00:59:09 +0200 Subject: [PATCH 68/72] refactor risky promise chains to use await --- src/core/owner.js | 55 +++++++++++------------------- src/core/task.js | 8 ++--- src/reporting/api/masterReport.js | 9 +++-- src/reporting/api/project.js | 25 ++++---------- src/reporting/api/projectReport.js | 16 ++++----- 5 files changed, 43 insertions(+), 70 deletions(-) diff --git a/src/core/owner.js b/src/core/owner.js index eb3b53e..862fe5f 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -17,17 +17,15 @@ class Owner { * is authorized? * @returns {Promise} */ - authorized() { - if (!this.config.get('_checkToken')) return new Promise(r => r()); + async authorized() { + if (!this.config.get('_checkToken')) return; - return new Promise((resolve, reject) => { - this.client.get('broadcast_messages') - .then(() => resolve()) - .catch(e => { - if (e.statusCode === 403) resolve(); - reject(e); - }); - }); + try { + await this.client.get('broadcast_messages'); + } catch (e) { + if (e.statusCode === 403) return; + throw e; + } } /** @@ -35,40 +33,27 @@ class Owner { * @param fullPath group path, e.g. "group/subgroup" * @returns {Promise} */ - 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; + async getGroup(fullPath = this.config.get('project')) { + let groups = await this.client.get(`groups`); + if (groups.body.length === 0) throw 'Group not found'; - 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(); - }) - .catch(e => reject(e)); - }); + let filtered = groups.body.filter(group => group.full_path === fullPath); + if (filtered.length === 0) throw 'Group not found'; + this.groups = this.groups.concat(filtered); } /** * get sub groups * @returns {Promise} */ - getSubGroups() { - return new Promise((resolve, reject) => { - this.client.get(`groups`) - .then(groups => { - if (groups.body.length === 0) return resolve(); + async getSubGroups() { + let groups = await this.client.get(`groups`); + if (groups.body.length === 0) return; - let filtered = this._filterGroupsByParents(groups.body, this.groups.map(g => g.id)); - if (filtered.length === 0) return resolve(); + let filtered = this._filterGroupsByParents(groups.body, this.groups.map(g => g.id)); + if (filtered.length === 0) return; - this.groups = this.groups.concat(filtered); - resolve(); - }) - .catch(e => reject(e)); - }); + this.groups = this.groups.concat(filtered); } _filterGroupsByParents(groups, parents) { diff --git a/src/core/task.js b/src/core/task.js index 4b8aee5..4cfcfc9 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -129,11 +129,11 @@ class Task { }); } - getNotes() { - let promise = this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); - promise.then(notes => this.notes = notes); + async getNotes() { + let notes = await this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); + this.notes = notes; - return promise; + return notes; } createTime(time, created_at, note) { diff --git a/src/reporting/api/masterReport.js b/src/reporting/api/masterReport.js index 4546f3d..684cf96 100644 --- a/src/reporting/api/masterReport.js +++ b/src/reporting/api/masterReport.js @@ -37,12 +37,12 @@ class MasterReport { * @param input * @param model * @param {(() => void)|false} [advance] - * @returns {*|Promise} + * @returns {Promise} */ - process(input, model, advance = false) { + async process(input, model, advance = false) { let collect = []; - let promise = parallel(this[input], async data => { + await parallel(this[input], async data => { let item = new model(this.config, data, this.client, this.projects[data.project_id]); item.recordTimelogs(timelogsFor(this.timelogs, input, data)); @@ -54,8 +54,7 @@ class MasterReport { if (advance) advance(); }, this.config); - promise.then(() => this[input] = this.filter(collect)); - return promise; + this[input] = this.filter(collect); } /** diff --git a/src/reporting/api/project.js b/src/reporting/api/project.js index f3530a1..0903cd2 100755 --- a/src/reporting/api/project.js +++ b/src/reporting/api/project.js @@ -32,25 +32,14 @@ class Project { * set members * @returns {Promise} */ - members() { - return new Promise((resolve, reject) => { - this.client.get(`projects/${this.id}/members`) - .then(response => { - this.projectMembers = this.projectMembers.concat(response.body); - return Promise.resolve(); - }) - .then(() => { - if (!this.data.namespace || !this.data.namespace.kind || this.data.namespace.kind !== "group") return resolve(); + async members() { + let response = await this.client.get(`projects/${this.id}/members`); + this.projectMembers = this.projectMembers.concat(response.body); - this.client.get(`groups/${this.data.namespace.id}/members`) - .then(response => { - this.projectMembers = this.projectMembers.concat(response.body); - resolve(); - }) - .catch(e => reject(e)); - }) - .catch(e => reject(e)); - }); + if (!this.data.namespace || !this.data.namespace.kind || this.data.namespace.kind !== "group") return; + + let groupResponse = await this.client.get(`groups/${this.data.namespace.id}/members`); + this.projectMembers = this.projectMembers.concat(groupResponse.body); } /* diff --git a/src/reporting/api/projectReport.js b/src/reporting/api/projectReport.js index d88a692..14d136d 100644 --- a/src/reporting/api/projectReport.js +++ b/src/reporting/api/projectReport.js @@ -55,24 +55,24 @@ class ProjectReport { * query and set merge requests * @returns {Promise} */ - getMergeRequests() { - let promise = this.client.all(`projects/${this.project.id}/merge_requests${this.params()}`); + async getMergeRequests() { + let mergeRequests = await this.client.all(`projects/${this.project.id}/merge_requests${this.params()}`); let excludes = this.config.get('excludeByLabels'); - promise.then(mergeRequests => this.mergeRequests = excludeByLabel(mergeRequests, excludes)); + this.mergeRequests = excludeByLabel(mergeRequests, excludes); - return promise; + return this.mergeRequests; } /** * query and set issues * @returns {Promise} */ - getIssues() { - let promise = this.client.all(`projects/${this.project.id}/issues${this.params()}`); + async getIssues() { + let issues = await this.client.all(`projects/${this.project.id}/issues${this.params()}`); let excludes = this.config.get('excludeByLabels'); - promise.then(issues => this.issues = excludeByLabel(excludeMoved(issues), excludes)); + this.issues = excludeByLabel(excludeMoved(issues), excludes); - return promise; + return this.issues; } /** From cc5344d0432ef2aa21d04a914ccafc88fd49b642 Mon Sep 17 00:00:00 2001 From: ndu Date: Sun, 12 Jul 2026 01:10:11 +0200 Subject: [PATCH 69/72] move padLeft to local function instead of global monkeypatch --- src/core/global.d.ts | 8 -------- src/core/time.js | 14 +++++++------- 2 files changed, 7 insertions(+), 15 deletions(-) delete mode 100644 src/core/global.d.ts diff --git a/src/core/global.d.ts b/src/core/global.d.ts deleted file mode 100644 index 66a5455..0000000 --- a/src/core/global.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export {}; - -// Number.prototype.padLeft is monkeypatched onto the global Number in time.js -declare global { - interface Number { - padLeft(n: number, str?: string): string; - } -} diff --git a/src/core/time.js b/src/core/time.js index 2cde9d7..8f3d08e 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -8,9 +8,9 @@ const roundedRegex = /(\[\%([^\>\]]*)\:([^\]]*)\])/ig; const conditionalSimpleRegex = /([0-9]*)\>(.*)/ig; const defaultRegex = /(\[\%([^\]]*)\])/ig; -Number.prototype.padLeft = function (n, str) { - return Array(Math.max(0, n - String(this).length + 1)).join(str || '0') + this; -}; +function padLeft(value, n, str) { + return Array(Math.max(0, n - String(value).length + 1)).join(str || '0') + value; +} /** * time model @@ -116,18 +116,18 @@ class Time { inserts.days_overall = input / secondsInADay; inserts.days_overall_comma = inserts.days_overall.toString().replace('.', ','); inserts.days = Math.floor(inserts.days_overall); - inserts.Days = inserts.days.padLeft(2, '0'); + inserts.Days = padLeft(inserts.days, 2, '0'); inserts.hours_overall = input / secondsInAnHour; inserts.hours_overall_comma = inserts.hours_overall.toString().replace('.', ','); inserts.hours = Math.floor((input % secondsInADay) / secondsInAnHour); - inserts.Hours = inserts.hours.padLeft(2, '0'); + inserts.Hours = padLeft(inserts.hours, 2, '0'); inserts.minutes_overall = input / secondsInAMinute; inserts.minutes_overall_comma = (inserts.minutes_overall).toString().replace('.', ','); inserts.minutes = Math.floor(((input % secondsInADay) % secondsInAnHour) / secondsInAMinute); - inserts.Minutes = inserts.minutes.padLeft(2, '0'); + inserts.Minutes = padLeft(inserts.minutes, 2, '0'); inserts.seconds_overall = input; inserts.seconds = ((input % secondsInADay) % secondsInAnHour) % secondsInAMinute; - inserts.Seconds = inserts.seconds.padLeft(2, '0'); + inserts.Seconds = padLeft(inserts.seconds, 2, '0'); // rounded while ((match = roundedRegex.exec(format)) !== null) { From 5c630033242b2a5ea0397366d6788b2290005ca6 Mon Sep 17 00:00:00 2001 From: ndu Date: Sun, 12 Jul 2026 01:19:35 +0200 Subject: [PATCH 70/72] refactor and optimise currentframe --- spec/reporting/filters.spec.js | 2 +- spec/timekeeping/timekeeper.spec.js | 2 +- src/core/{ => api}/gitlab-client.js | 2 +- src/core/{ => api}/issue.js | 0 src/core/{ => api}/mergeRequest.js | 0 src/core/{ => api}/owner.js | 2 +- src/core/{ => api}/task.js | 8 +-- src/core/time.js | 8 +-- src/reporting/api/project.js | 2 +- src/reporting/api/projectReport.js | 4 +- src/reporting/commands/report.js | 2 +- src/reporting/{api => }/dayReport.js | 0 src/reporting/{api => }/filters.js | 0 src/reporting/{api => }/masterReport.js | 16 ++--- src/reporting/{api => }/reportCollection.js | 2 +- src/reporting/reportRunner.js | 6 +- src/reporting/stats.js | 2 +- src/timekeeping/commands/sync.js | 2 +- src/timekeeping/timekeeper.js | 71 +++++++++++++-------- 19 files changed, 75 insertions(+), 56 deletions(-) rename src/core/{ => api}/gitlab-client.js (99%) rename src/core/{ => api}/issue.js (100%) rename src/core/{ => api}/mergeRequest.js (100%) rename src/core/{ => api}/owner.js (98%) rename src/core/{ => api}/task.js (97%) rename src/reporting/{api => }/dayReport.js (100%) rename src/reporting/{api => }/filters.js (100%) rename src/reporting/{api => }/masterReport.js (84%) rename src/reporting/{api => }/reportCollection.js (91%) diff --git a/spec/reporting/filters.spec.js b/spec/reporting/filters.spec.js index 7ebf5b6..d1567f1 100644 --- a/spec/reporting/filters.spec.js +++ b/spec/reporting/filters.spec.js @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { excludeByLabel, excludeMoved } from '../../src/reporting/api/filters.js'; +import { excludeByLabel, excludeMoved } from '../../src/reporting/filters.js'; function item(iid, labels = []) { return { iid, labels }; diff --git a/spec/timekeeping/timekeeper.spec.js b/spec/timekeeping/timekeeper.spec.js index 49c2ed7..1566e52 100644 --- a/spec/timekeeping/timekeeper.spec.js +++ b/spec/timekeeping/timekeeper.spec.js @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import Config from '../../src/core/config.js'; -import Task from '../../src/core/task.js'; +import Task from '../../src/core/api/task.js'; import Timekeeper from '../../src/timekeeping/timekeeper.js'; function fakeFrames(frames) { diff --git a/src/core/gitlab-client.js b/src/core/api/gitlab-client.js similarity index 99% rename from src/core/gitlab-client.js rename to src/core/api/gitlab-client.js index 00250dc..98d90a0 100755 --- a/src/core/gitlab-client.js +++ b/src/core/api/gitlab-client.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; import { throttledQueue } from 'throttled-queue'; -import parallel from './parallel.js'; +import parallel from '../parallel.js'; /** * GitLab REST/GraphQL client: owns the request throttle, single/parallel diff --git a/src/core/issue.js b/src/core/api/issue.js similarity index 100% rename from src/core/issue.js rename to src/core/api/issue.js diff --git a/src/core/mergeRequest.js b/src/core/api/mergeRequest.js similarity index 100% rename from src/core/mergeRequest.js rename to src/core/api/mergeRequest.js diff --git a/src/core/owner.js b/src/core/api/owner.js similarity index 98% rename from src/core/owner.js rename to src/core/api/owner.js index 862fe5f..bc24bae 100755 --- a/src/core/owner.js +++ b/src/core/api/owner.js @@ -1,5 +1,5 @@ import GitlabClient from './gitlab-client.js'; -import parallel from './parallel.js'; +import parallel from '../parallel.js'; /** * owner model diff --git a/src/core/task.js b/src/core/api/task.js similarity index 97% rename from src/core/task.js rename to src/core/api/task.js index 4cfcfc9..ac7e6a7 100644 --- a/src/core/task.js +++ b/src/core/api/task.js @@ -1,8 +1,8 @@ -import dayjs from './dayjs.js'; +import dayjs from '../dayjs.js'; import GitlabClient from './gitlab-client.js'; -import Time from './time.js'; -import chargeRatio from './billing.js'; -import Project from '../reporting/api/project.js'; +import Time from '../time.js'; +import chargeRatio from '../billing.js'; +import Project from '../../reporting/api/project.js'; class Task { /** diff --git a/src/core/time.js b/src/core/time.js index 8f3d08e..cc122a5 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -85,7 +85,7 @@ class Time { let match, parsed; if ((match = regex.exec(string)) === null) return false; - parsed = Object.fromEntries(mappings.map((key, i) => [key, match[i] === undefined ? 0 : match[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) @@ -138,9 +138,9 @@ class Time { decimals = conditionalMatch[1] } - decimals = parseInt(decimals); - time = Math.ceil(inserts[match[2]] * Math.pow(10, decimals)) / Math.pow(10, decimals); - output = output.replace(match[0], time !== 0 && conditionalMatch ? time + conditionalMatch[2] : time); + let iDecimals = parseInt(decimals); + time = Math.ceil(inserts[match[2]] * Math.pow(10, iDecimals)) / Math.pow(10, iDecimals); + output = output.replace(match[0], (time !== 0 && conditionalMatch ? time + conditionalMatch[2] : time).toString()); } // conditionals diff --git a/src/reporting/api/project.js b/src/reporting/api/project.js index 0903cd2..fc31bb0 100755 --- a/src/reporting/api/project.js +++ b/src/reporting/api/project.js @@ -1,4 +1,4 @@ -import GitlabClient from '../../core/gitlab-client.js'; +import GitlabClient from '../../core/api/gitlab-client.js'; /** * project model diff --git a/src/reporting/api/projectReport.js b/src/reporting/api/projectReport.js index 14d136d..61f578e 100644 --- a/src/reporting/api/projectReport.js +++ b/src/reporting/api/projectReport.js @@ -1,7 +1,7 @@ -import GitlabClient from '../../core/gitlab-client.js'; +import GitlabClient from '../../core/api/gitlab-client.js'; import Project from './project.js'; import fetchTimelogs from './timelogs.js'; -import {excludeByLabel, excludeMoved} from './filters.js'; +import {excludeByLabel, excludeMoved} from '../filters.js'; import Config from '../../core/config.js'; /** diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index c6fddad..81a9fc8 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -2,7 +2,7 @@ import fs from 'fs'; import {Command} from 'commander'; import Cli from '../../core/cli.js'; import Args from '../../core/args.js'; -import GitlabClient from '../../core/gitlab-client.js'; +import GitlabClient from '../../core/api/gitlab-client.js'; import {buildReportConfig, validateReportConfig} from '../reportConfigBuilder.js'; import {runReport, Output} from '../reportRunner.js'; diff --git a/src/reporting/api/dayReport.js b/src/reporting/dayReport.js similarity index 100% rename from src/reporting/api/dayReport.js rename to src/reporting/dayReport.js diff --git a/src/reporting/api/filters.js b/src/reporting/filters.js similarity index 100% rename from src/reporting/api/filters.js rename to src/reporting/filters.js diff --git a/src/reporting/api/masterReport.js b/src/reporting/masterReport.js similarity index 84% rename from src/reporting/api/masterReport.js rename to src/reporting/masterReport.js index 684cf96..5dc16ad 100644 --- a/src/reporting/api/masterReport.js +++ b/src/reporting/masterReport.js @@ -1,8 +1,8 @@ -import GitlabClient from '../../core/gitlab-client.js'; -import parallel from '../../core/parallel.js'; -import Issue from '../../core/issue.js'; -import MergeRequest from '../../core/mergeRequest.js'; -import {timelogsFor} from './timelogs.js'; +import GitlabClient from '../core/api/gitlab-client.js'; +import parallel from '../core/parallel.js'; +import Issue from '../core/api/issue.js'; +import MergeRequest from '../core/api/mergeRequest.js'; +import {timelogsFor} from './api/timelogs.js'; /** * aggregates ProjectReports and processes their issues/merge requests @@ -11,12 +11,10 @@ class MasterReport { /** * constructor. * @param config - * @param client */ - constructor(config, client = new GitlabClient(config)) { + constructor(config, client) { this.config = config; this.client = client; - this.projects = {}; this.issues = []; this.mergeRequests = []; @@ -59,7 +57,7 @@ class MasterReport { /** * merge a project report into this report - * @param {import('./projectReport.js').default} report + * @param {import('./api/projectReport.js').default} report */ merge(report) { this.issues = this.issues.concat(report.issues); diff --git a/src/reporting/api/reportCollection.js b/src/reporting/reportCollection.js similarity index 91% rename from src/reporting/api/reportCollection.js rename to src/reporting/reportCollection.js index 907a5a2..11a505a 100755 --- a/src/reporting/api/reportCollection.js +++ b/src/reporting/reportCollection.js @@ -1,4 +1,4 @@ -import parallel from '../../core/parallel.js'; +import parallel from '../core/parallel.js'; class ReportCollection { constructor(config) { diff --git a/src/reporting/reportRunner.js b/src/reporting/reportRunner.js index d22124f..be6d260 100644 --- a/src/reporting/reportRunner.js +++ b/src/reporting/reportRunner.js @@ -1,10 +1,10 @@ import pc from 'picocolors'; import Cli from '../core/cli.js'; -import Owner from '../core/owner.js'; +import Owner from '../core/api/owner.js'; import Project from './api/project.js'; import ProjectReport from './api/projectReport.js'; -import MasterReport from './api/masterReport.js'; -import ReportCollection from './api/reportCollection.js'; +import MasterReport from './masterReport.js'; +import ReportCollection from './reportCollection.js'; import parallel from '../core/parallel.js'; // output backends are imported lazily so timekeeping commands never pull in diff --git a/src/reporting/stats.js b/src/reporting/stats.js index 0cd89df..048d7ef 100644 --- a/src/reporting/stats.js +++ b/src/reporting/stats.js @@ -1,4 +1,4 @@ -import DayReport from './api/dayReport.js'; +import DayReport from './dayReport.js'; /** * Aggregate a merged report into the numbers the output formats render: diff --git a/src/timekeeping/commands/sync.js b/src/timekeeping/commands/sync.js index ad3edce..709c649 100755 --- a/src/timekeeping/commands/sync.js +++ b/src/timekeeping/commands/sync.js @@ -1,7 +1,7 @@ import {Command} from 'commander'; import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; -import Owner from '../../core/owner.js'; +import Owner from '../../core/api/owner.js'; function sync(configLoader) { const sync = new Command('sync') diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 85e725f..bc01b18 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -1,7 +1,7 @@ import Fs from '../core/filesystem.js'; import Frame from './storage/frame.js'; -import Issue from '../core/issue.js'; -import MergeRequest from '../core/mergeRequest.js'; +import Issue from '../core/api/issue.js'; +import MergeRequest from '../core/api/mergeRequest.js'; import FrameCollection from './storage/frameCollection.js'; const classes = { @@ -18,25 +18,48 @@ class Timekeeper { /** * Path to the pointer file that names the currently - * active (has no contents if stopped) + * active frame (has no contents if stopped). config.frameDir + * can change after construction (eg. once `project` is set), + * so this stays a getter rather than a value cached once. */ - _currentFile() { + get currentFile() { return Fs.join(this.config.frameDir, CURRENT_FILE); } /** - * id of the currently active frame, or null if none is running. + * The currently active frame, or null if none is running. * Only one frame can be active at a time, so this is a direct * lookup instead of scanning every frame file. + * @returns {Frame|null} */ - _currentId() { - if (!Fs.exists(this._currentFile())) { + getCurrentFrame() { + if (!Fs.exists(this.currentFile)) { let running = new FrameCollection(this.config).frames.find(frame => frame.stop === null); - Fs.writeText(this._currentFile(), running ? running.id : ''); + Fs.writeText(this.currentFile, running ? running.id : ''); + return running; } - let id = Fs.readText(this._currentFile()).trim(); + let id = Fs.readText(this.currentFile).trim(); - return id || null; + if (!id) return null; + + let f = Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json')); + if(f.stop != null) { + // lost sync. reset + Fs.remove(this.currentFile); + let running = new FrameCollection(this.config).frames.find(frame => frame.stop === null); + Fs.writeText(this.currentFile, running ? running.id : ''); + f = running; + } + return f; + } + + /** + * Records the given frame as the currently active one, or clears + * it when called with null/undefined. + * @param {Frame|null} [frame] + */ + setCurrentFrame(frame) { + Fs.writeText(this.currentFile, frame ? frame.id : ''); } /** @@ -187,11 +210,11 @@ class Timekeeper { * @returns {Promise} */ async status() { - let id = this._currentId(); + let frame = this.getCurrentFrame(); - if (!id) return []; + if (!frame) return []; - return [Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json'))]; + return [frame]; } /** @@ -259,11 +282,11 @@ class Timekeeper { async start(project, type, id, note) { this.config.set('project', project); - if (this._currentId()) + if (this.getCurrentFrame()) 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); + this.setCurrentFrame(frame); return frame; } @@ -273,12 +296,12 @@ class Timekeeper { * @returns {Promise} */ async stop() { - let id = this._currentId(); + let frame = this.getCurrentFrame(); - if (!id) throw new Error('No projects started.'); + if (!frame) throw new Error('No projects started.'); - let frame = Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json')).stopMe(); - Fs.truncate(this._currentFile()); + frame.stopMe(); + this.setCurrentFrame(null); return [frame]; } @@ -288,14 +311,12 @@ class Timekeeper { * @returns {Promise} */ async cancel() { - let id = this._currentId(); + let frame = this.getCurrentFrame(); - if (!id) throw new Error('No projects started.'); + if (!frame) 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.truncate(this._currentFile()); + Fs.remove(Fs.join(this.config.frameDir, frame.id + '.json')); + this.setCurrentFrame(null); return [frame]; } From ffe26f389ef1fece07f8f0a18025ff36baa31ad6 Mon Sep 17 00:00:00 2001 From: ndu Date: Sun, 12 Jul 2026 02:16:55 +0200 Subject: [PATCH 71/72] delete dead code --- src/core/api/gitlab-client.js | 1 - src/core/filesystem.js | 4 ---- src/core/time.js | 24 ------------------------ src/reporting/masterReport.js | 1 - src/timekeeping/commands/edit.js | 3 +-- 5 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/core/api/gitlab-client.js b/src/core/api/gitlab-client.js index 98d90a0..4e81fa5 100755 --- a/src/core/api/gitlab-client.js +++ b/src/core/api/gitlab-client.js @@ -1,4 +1,3 @@ -import crypto from 'crypto'; import { throttledQueue } from 'throttled-queue'; import parallel from '../parallel.js'; diff --git a/src/core/filesystem.js b/src/core/filesystem.js index 69f965a..b2e0953 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -20,10 +20,6 @@ class Filesystem { return fs.writeFileSync(file, data); } - static truncate(file) { - return fs.truncateSync(file); - } - static open(file) { let editor = process.env.VISUAL; diff --git a/src/core/time.js b/src/core/time.js index cc122a5..dfcf122 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -1,8 +1,6 @@ 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']; -const regex = /^(?:([-])\s*)?(?:(\d+)mo\s*)?(?:(\d+)w\s*)?(?:(\d+)d\s*)?(?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s\s*)?$/; const conditionalRegex = /(\[\%([^\>\]]*)\>([^\]]*)\])/ig; const roundedRegex = /(\[\%([^\>\]]*)\:([^\]]*)\])/ig; const conditionalSimpleRegex = /([0-9]*)\>(.*)/ig; @@ -73,28 +71,6 @@ class Time { return this.config && this.config.get('weeksPerMonth') ? parseInt(this.config.get('weeksPerMonth')) : 4; } - /** - * parse human readable to seconds - * @param string - * @param hoursPerDay - * @param daysPerWeek - * @param weeksPerMonth - * @returns {*} - */ - static parse(string, hoursPerDay = 8, daysPerWeek = 5, weeksPerMonth = 4) { - let match, parsed; - - if ((match = regex.exec(string)) === null) return false; - 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) - + (parseInt(parsed.hours) * 60 * 60) - + (parseInt(parsed.days) * hoursPerDay * 60 * 60) - + (parseInt(parsed.weeks) * daysPerWeek * hoursPerDay * 60 * 60) - + (parseInt(parsed.months) * weeksPerMonth * daysPerWeek * hoursPerDay * 60 * 60)); - } - /** * get human readable * @param input diff --git a/src/reporting/masterReport.js b/src/reporting/masterReport.js index 5dc16ad..a6c7645 100644 --- a/src/reporting/masterReport.js +++ b/src/reporting/masterReport.js @@ -1,4 +1,3 @@ -import GitlabClient from '../core/api/gitlab-client.js'; import parallel from '../core/parallel.js'; import Issue from '../core/api/issue.js'; import MergeRequest from '../core/api/mergeRequest.js'; diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index a66cdda..de04453 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -1,7 +1,6 @@ import {Command} from 'commander'; import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; -import Time from '../../core/time.js'; import Frame from '../storage/frame.js'; import select, { Separator } from '@inquirer/select'; import dayjs from '../../core/dayjs.js'; @@ -228,7 +227,7 @@ function edit(configLoader) { .action((id, opts ,program) => { let config = configLoader(); -let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeFormat', 'log'); +config.set('timeFormat', program.opts().time_format); let timekeeper = new Timekeeper(config); const listSize = program.opts().listsize; From 621b6d2934888aec92c43cb53d2b7bd69cf3fa08 Mon Sep 17 00:00:00 2001 From: ndu Date: Tue, 14 Jul 2026 23:10:51 +0200 Subject: [PATCH 72/72] 1.9.0-beta0002 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index df74ffb..53709ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-time-tracker", - "version": "1.9.0-beta0001", + "version": "1.9.0-beta0002", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-time-tracker", - "version": "1.9.0-beta0001", + "version": "1.9.0-beta0002", "license": "GPL-2.0", "dependencies": { "@inquirer/confirm": "^6.1.1", diff --git a/package.json b/package.json index 700d169..2f5f291 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitlab-time-tracker", - "version": "1.9.0-beta0001", + "version": "1.9.0-beta0002", "type": "module", "description": "A command line interface for GitLabs time tracking feature.", "bugs": { diff --git a/src/version.js b/src/version.js index 71a66bc..65944a9 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -let version = '1.9.0-beta0001'; export default version; +let version = '1.9.0-beta0002'; export default version;