diff --git a/lib/tw-log.js b/lib/tw-log.js index e482acf..8c4b882 100644 --- a/lib/tw-log.js +++ b/lib/tw-log.js @@ -2,6 +2,10 @@ * @file TW Log main library. */ +import nconf from 'nconf'; +import request from 'request'; +import isNumeric from 'isnumeric'; + /** * Add custom date format. * @@ -16,221 +20,216 @@ Date.prototype.format = function () { return datestring; }; -module.exports = { - config: {}, - /** - * Gets the config object. - * @returns {*} - */ - getConfig: (configfile = '') => { - const nconf = require('nconf'); - nconf - .use('file', { file: configfile }) - .load(); - - if (!Object.keys(nconf.get()).length) { - console.error('Could not read from file %s.', logfile); - console.log(); - process.exit(1001); - } +let config = {}; - this.config = nconf.get(); - let project; +export const getConfig = (configfile = '') => { + nconf.use('file', { file: configfile }).load(); - // Allow simple format in config, but convert to full format before use. - for (project in this.config.map.project) { - if (!(this.config.map.project[project] instanceof Object)) { - this.config.map.project[project] = { - 'id': this.config.map.project[project], - 'task': {} - } - } - } - return this.config; - }, - /** - * Validates data from CSV. - * - * @param data - * @returns {boolean} - */ - validateData: data => { - // Ensure all fields exist. - let hasError = false; - let columns = ['date', 'notes', 'project', 'time']; - - for (let i = 0, tot = columns.length; i < tot; i++) { - let column = columns[i]; - if (data[this.config.map.csv[column]] === undefined) { - console.error('Column %s (%s) does not exist in the CSV file.', this.config.map.csv[column], column); - hasError = true; - } - } - if (hasError) { - process.exit(1002); - } + if (!Object.keys(nconf.get()).length) { + console.error('Could not read from file %s.', configfile); + console.log(); + process.exit(1001); + } + + config = nconf.get(); + let project; - if (this.config.map.project[data[this.config.map.csv.project]] === undefined) { - console.error('No project ID was found for "%s". Add it to your log.config.json file.', data[this.config.map.csv.project]); - process.exit(1003); + // Allow simple format in config, but convert to full format before use. + for (project in config.map.project) { + if (!(config.map.project[project] instanceof Object)) { + config.map.project[project] = { + id: config.map.project[project], + task: {}, + }; } + } + return config; +}; - return true; - }, - /** - * Parses data from CSV. - * - * @param data - * @param timeEntries - */ - parseData: (data, timeEntries) => { - let date = new Date(data[this.config.map.csv.date]).format(); - let notes = data[this.config.map.csv.notes]; - let project = data[this.config.map.csv.project]; - let task = data[this.config.map.csv.task]; - let time = data[this.config.map.csv.time].split(':'); - - let description = ''; - if (task) { - if (notes) description = task + ': ' + notes; - else description = task; +export const validateData = (data) => { + // Ensure all fields exist. + let hasError = false; + let columns = ['date', 'notes', 'project', 'time']; + + for (let i = 0, tot = columns.length; i < tot; i++) { + let column = columns[i]; + if (data[config.map.csv[column]] === undefined) { + console.error( + 'Column %s (%s) does not exist in the CSV file.', + config.map.csv[column], + column + ); + hasError = true; } - else if (notes) description = notes; + } + if (hasError) { + process.exit(1002); + } - let configProject = this.config.map.project[project]; + if (config.map.project[data[config.map.csv.project]] === undefined) { + console.error( + 'No project ID was found for "%s". Add it to your log.config.json file.', + data[config.map.csv.project] + ); + process.exit(1003); + } - if (timeEntries[project] === undefined) { - timeEntries[project] = {}; - } - if (timeEntries[project][description] === undefined) { - timeEntries[project][description] = {}; - } - if (timeEntries[project][description][date] === undefined) { - timeEntries[project][description][date] = { - json: { - 'project-id': configProject.id, - 'description': description, - 'person-id': this.config.personId, - 'date': date, - // 'time': '10:10', - 'hours': +parseInt(time[0]), - 'minutes': +parseInt(time[1]), - 'isbillable': true - }, - data: { - project: project, - projectId: configProject.id, - task: task, - taskId: configProject.task[task] ? configProject.task[task] : null - } - } - } - else { - // Update hours & minutes only. - timeEntries[project][description][date].json.hours += parseInt(time[0]); - timeEntries[project][description][date].json.minutes += parseInt(time[1]); - - // Split minutes into hours and minutes. - let minutes = timeEntries[project][description][date].json.minutes; - if (minutes >= 60) { - let hours = Math.floor(minutes / 60); - minutes = minutes % 60; - - timeEntries[project][description][date].json.hours += hours; - timeEntries[project][description][date].json.minutes = minutes; - } + return true; +}; + +export const parseData = (row, timeEntries) => { + let date = new Date(row[config.map.csv.date]).format(); + let notes = row[config.map.csv.notes]; + let project = row[config.map.csv.project]; + let task = row[config.map.csv.task]; + let hours = 0; + let minutes = 0; + + if (row[config.map.csv.time].includes(':')) { + let time = row[config.map.csv.time].split(':'); + hours = parseInt(time[0]); + minutes = parseInt(time[1]); + } else { + minutes = parseInt(row[config.map.csv.time]); + } + if (minutes >= 60) { + hours = Math.floor(minutes / 60); + minutes = minutes % 60; + } + + let description = ''; + if (task) { + if (notes) { + description = task + ': ' + notes; + } else { + description = task; } - }, - /** - * Logs time entries. - * - * @param timeEntries - * @param {bool} simulate - */ - logTime: (timeEntries, simulate = false)=> { - console.log('Logging time...'); - - // Talk to teamwork API. - let request = require('request'); - let isNumeric = require("isnumeric"); - - let base64 = new Buffer(this.config.key + ':xxx').toString('base64'); - - let host = 'https://' + this.config.company + '.teamworkpm.net'; - let pathProject = '/projects/{project-id}/time_entries.json'; - let pathTask = '/tasks/{task-id}/time_entries.json'; - - let options = { - method: 'POST', - encoding: 'utf8', - followRedirect: true, - headers: { - 'Authorization': 'BASIC ' + base64, - 'Content-Type': 'application/json' - } + } else if (notes) { + description = notes; + } + + let configProject = config.map.project[project]; + + if (timeEntries[project] === undefined) { + timeEntries[project] = {}; + } + if (timeEntries[project][description] === undefined) { + timeEntries[project][description] = {}; + } + if (timeEntries[project][description][date] === undefined) { + timeEntries[project][description][date] = { + json: { + 'project-id': configProject.id, + description: description, + 'person-id': config.personId, + date: date, + hours: hours, + minutes: minutes, + isbillable: true, + }, + data: { + project: project, + projectId: configProject.id, + task: task, + taskId: configProject.task[task] ? configProject.task[task] : null, + }, }; + } else { + // Update hours & minutes only. + timeEntries[project][description][date].json.hours += hours; + timeEntries[project][description][date].json.minutes += minutes; - let projectId, - description, - date, - descriptionArray; - - for (projectId in timeEntries) { - for (description in timeEntries[projectId]) { - for (date in timeEntries[projectId][description]) { - let timeEntry = timeEntries[projectId][description][date]; - - if (timeEntry.data.taskId === null) { - // See if the task description includes a taskId. - descriptionArray = description.split(' - '); - if (descriptionArray.length > 1 && isNumeric(descriptionArray[0].trim())) { - // A teamwork task is associated with this time entry. - options.uri = host + pathTask.replace('{task-id}', descriptionArray[0].trim()); - timeEntry.json.description = descriptionArray[1].trim(); - } - else if (description.toUpperCase().substr(0, 4) == '[TW:') { - // A teamwork task is associated with this time entry. - // ID could be 6-8 characters so strip any non numeric characters. - let tw_task_id = description.substr(4, 9).replace(/\D/g,''); - options.uri = host + pathTask.replace('{task-id}', tw_task_id); - timeEntry.json.description = description.substr(12).trim(); - } - else { - // No teamwork task associated with this time entry. - options.uri = host + pathProject.replace('{project-id}', timeEntry.data.projectId); - } - } - else { + let thisMinutes = timeEntries[project][description][date].json.minutes; + + if (thisMinutes >= 60) { + const thisHours = Math.floor(thisMinutes / 60); + thisMinutes = thisMinutes % 60; + + timeEntries[project][description][date].json.hours += thisHours; + timeEntries[project][description][date].json.minutes = thisMinutes; + } + } +}; + +export const logTime = (timeEntries, simulate = false) => { + console.log('Logging time...'); + + // Talk to teamwork API. + + let base64 = new Buffer.from(config.key + ':xxx').toString('base64'); + + let host = 'https://' + config.company + '.teamworkpm.net'; + let pathProject = '/projects/{project-id}/time_entries.json'; + let pathTask = '/tasks/{task-id}/time_entries.json'; + + let options = { + method: 'POST', + encoding: 'utf8', + followRedirect: true, + headers: { + Authorization: 'BASIC ' + base64, + 'Content-Type': 'application/json', + }, + }; + + let projectId, description, date, descriptionArray; + + for (projectId in timeEntries) { + for (description in timeEntries[projectId]) { + for (date in timeEntries[projectId][description]) { + let timeEntry = timeEntries[projectId][description][date]; + + if (timeEntry.data.taskId === null) { + // See if the task description includes a taskId. + descriptionArray = description.split(' - '); + if (descriptionArray.length > 1 && isNumeric(descriptionArray[0].trim())) { // A teamwork task is associated with this time entry. - options.uri = host + pathTask.replace('{task-id}', timeEntry.data.taskId); - } - options.json = { - 'time-entry': timeEntry.json - }; - - console.log('Logging %s:%s hrs for project %s, description: "%s".', timeEntry.json.hours, (timeEntry.json.minutes < 10 ? '0' : '') + timeEntry.json.minutes, timeEntry.data.project, timeEntry.json.description); - - if (!simulate) { - request(options, function (error, response, body) { - if (error) { - return console.log('ERROR:', error); - } - else if (response.statusCode < 200 || response.statusCode > 201) { - console.log('STATUS ERROR:', response.statusCode); - return console.log(body); - } - if (response.statusCode == 200) { - // Updated time entry. - } - else if (response.statusCode == 201) { - // Created new time entry. - } - }); + options.uri = host + pathTask.replace('{task-id}', descriptionArray[0].trim()); + timeEntry.json.description = descriptionArray[1].trim(); + } else if (description.toUpperCase().substr(0, 4) == '[TW:') { + // A teamwork task is associated with this time entry. + // ID could be 6-8 characters so strip any non numeric characters. + let tw_task_id = description.substr(4, 9).replace(/\D/g, ''); + options.uri = host + pathTask.replace('{task-id}', tw_task_id); + timeEntry.json.description = description.substr(12).trim(); + } else { + // No teamwork task associated with this time entry. + options.uri = host + pathProject.replace('{project-id}', timeEntry.data.projectId); } + } else { + // A teamwork task is associated with this time entry. + options.uri = host + pathTask.replace('{task-id}', timeEntry.data.taskId); + } + options.json = { + 'time-entry': timeEntry.json, + }; + + console.log( + 'Logging %s:%s hrs for project %s, description: "%s".', + timeEntry.json.hours, + (timeEntry.json.minutes < 10 ? '0' : '') + timeEntry.json.minutes, + timeEntry.data.project, + timeEntry.json.description + ); + + if (!simulate) { + request(options, function (error, response, body) { + if (error) { + return console.log('ERROR:', error); + } else if (response.statusCode < 200 || response.statusCode > 201) { + console.log('STATUS ERROR:', response.statusCode); + return console.log(body); + } + if (response.statusCode == 200) { + // Updated time entry. + } else if (response.statusCode == 201) { + // Created new time entry. + } + }); } } } - console.log('Logging completed.'); - console.log(); } + console.log('Logging completed.'); + console.log(); }; diff --git a/log.js b/log.js index 2ca7e46..3b6235f 100755 --- a/log.js +++ b/log.js @@ -1,64 +1,65 @@ #! /usr/bin/env node -const Log = require('./lib/tw-log') +import { existsSync } from 'fs'; +import path from 'path' +import { Command } from 'commander'; +import { parseFile } from '@fast-csv/parse'; +// import { fromPath } from 'fast-csv'; +import { getConfig, validateData, parseData, logTime } from './lib/tw-log.js'; // Load configuration data. -const config = Log.getConfig(__dirname + '/log.config.json'); +const __dirname = path.resolve(); +getConfig(__dirname + '/log.config.json'); /* - * Parse a CSV timesheet from Tyme. + * Parse a CSV timesheet. */ - -// Parse command line arguments. -var program = require('commander'); +const program = new Command(); program - .version('0.0.1') .option('-f, --file ', 'The full path and filename to the CSV export.') - .option('-s, --simulate', 'Simulate logging time.') - .on('--help', function() { - console.log(' Examples:'); - console.log(); - console.log(' $ log --file 20150511-20150517.csv'); - console.log(); - }) - .parse(process.argv); + .option('-s, --simulate', 'Simulate logging time.'); + +// Help suggestions. +const helpText = ` +Example: + $ ./log --file 20210919-20210925.csv +`; +program.addHelpText('after', helpText); +program.showSuggestionAfterError(); + +// Parse command line and get options. +program.parse(); +const options = program.opts(); +const file = options.file; // Output help if no arguments were supplied. -if (program.rawArgs.length <= 2) { +if (!file) { program.help(); } +// Check if csv file exists. +if (!existsSync(file)) { + console.error('Could not find %s.', file); + process.exit(1001); +} + // Check if we're running in simulated mode. -if (program.simulate) { +if (options.simulate) { console.log('Running in simulated mode.'); console.log(); } - // Prepare time entries. -var timeEntries = {}; +let timeEntries = {}; // Parse CSV file. -var file = program.file; console.log('Parsing file %s...', file); - -var csv = require('fast-csv'); -csv - .fromPath(file, {headers : true}) - .validate(Log.validateData) - .on('data', function(data) { - Log.parseData(data, timeEntries); - }) - .on('end', function() { - console.log('Parsing completed.'); - console.log(); - - Log.logTime(timeEntries, program.simulate); - }) - .on('error', function(error) { - // @todo This doesn't get triggered on file open errors. - console.error('Could not read from file %s.', file); - console.error(error.message); +parseFile(file, { headers: true }) + .validate((data) => validateData(data)) + .on('error', (error) => console.error(error)) + .on('data', (row) => parseData(row, timeEntries)) + .on('end', (rowCount) => { + console.log(`Parsed ${rowCount} rows.`); console.log(); - process.exit(1004); + logTime(timeEntries, options.simulate); }); diff --git a/package.json b/package.json index 9dd019a..b0dfd3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teamworkpm-log", - "version": "0.0.1", + "version": "1.0.0", "description": "Take a CSV file and log its data as time entries in TeamWork.", "main": "log.js", "type": "module",