UNPKG

workwatch

Version:

A Linux terminal program for honest worktime tracking and billing.

336 lines (322 loc) 11.5 kB
/** * This file is part of the WorkWatch, a Linux terminal program for honest * worktime tracking and billing. * * Copyright (C) 2020-2025 by Artur Rutkowski * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * WorkWatch is beeing developped and maintained by Artur (locust) Rutkwoski * <locust@mailbox.org> */ /** * This is a file-handling module representing a content and operations on * time measurement JSON file. Its getters provide and validate particular * fields of that file. Methods are manipulating and validating it. */ const fs = require("node:fs"); const WorkWatchFile = require("./file.js"); const utils = require("../utils.js"); function TimeMeasurement(jsonContent, pathToJSONFile = null) { // The day of measurement. Used when saving in the worklog. let measurementDay; // Provides measurementDay content. Object.defineProperty(this, "day", { get() { return measurementDay; }, enumerable: true, configurable: false }); // Extends base object. WorkWatchFile.call(this, jsonContent, pathToJSONFile); Object.setPrototypeOf(TimeMeasurement.prototype, WorkWatchFile.prototype); // Sets the measurement day when the file is stored on disk. if (this.filePath !== null) { fs.stat(this.filePath, {bigint: false}, (statError, stats) => { if (statError !== null && statError.code === "ENOENT") { // Finishes when the measurement file is not stored yet. return; } else if (statError === null) { measurementDay = stats.birthtime.toISOString().split("T")[0]; } else { throw statError; } }); } } // Represents a time field containing measured time in hh:mm format. Object.defineProperty(TimeMeasurement.prototype, "time", { get() { if ( "time" in this.fileContent && utils.verifyTimeValue(this.fileContent.time) ) { return this.fileContent.time; } else { const error = new Error("You have invalid measured time or your measurement file is empty."); error.code = "ERR_FILE_MEASUREMENT_NO_TIME_PROPERTY_OR_INVALID"; throw error; } }, enumerable: true, configurable: false }); // Represents a number field containing sequential number of a given // measurement in the particular day. Object.defineProperty(TimeMeasurement.prototype, "number", { get() { if ( "number" in this.fileContent && Number.isInteger(this.fileContent.number) && this.fileContent.number >= 1 ) { return this.fileContent.number; } else { const error = new Error("You have wrong measurement number or your measurement file is empty."); error.code = "ERR_FILE_MEASUREMENT_NO_NUMBER_PROPERTY_OR_WRONG_VALUE"; throw error; } }, enumerable: true, configurable: false }); // Represents an hoursRange field containing an array of objects. Each // object has "from" and "to" fields containing time of work in hh:mm format. Object.defineProperty(TimeMeasurement.prototype, "hoursRange", { get() { if ( "hoursRange" in this.fileContent && Array.isArray(this.fileContent.hoursRange) && this.fileContent.hoursRange.length > 0 ) { if ( this.fileContent.hoursRange.every( hourRange => ( "from" in hourRange && utils.verifyTimeValue(hourRange.from) ) && ( "to" in hourRange && utils.verifyTimeValue(hourRange.to) ) && ( utils.timeToMinutesNumber( hourRange.to ) > utils.timeToMinutesNumber( hourRange.from ) ) ) ) { return this.fileContent.hoursRange; } else { const error = new Error("You have invalid range of measured hours in your measurement file."); error.code = "ERR_FILE_MEASUREMENT_HOURSRANGE_WRONG_SYNTAX"; throw error; } } else { const error = new Error("You have no range of measured hours or your measurement file is empty."); error.code = "ERR_FILE_MEASUREMENT_NO_HOURSRANGE_PROPERTY_OR_INVALID"; throw error; } }, enumerable: true, configurable: false }); // Represents a project field naming a project or task you are working on. Object.defineProperty(TimeMeasurement.prototype, "project", { get() { if ( "project" in this.fileContent && typeof this.fileContent.project === "string" ) { return this.fileContent.project; } else { const error = new Error("You have wrong project name or your measurement file is empty."); error.code = "ERR_FILE_MEASUREMENT_NO_PROJECT_PROPERTY"; throw error; } }, enumerable: true, configurable: false }); // Verifies entire measurement file. Object.defineProperty(TimeMeasurement.prototype, "verify", { value: function verify() { return new Promise((resolve, reject) => { // Empty object check. if (Object.keys(this.fileContent).length === 0) { const error = new Error("Your measurement file is empty."); error.code = "ERR_FILE_MEASUREMENT_EMPTY"; reject(error); } // Extra fields check. No extra fields are allowed. if ( Object.keys(this.fileContent).filter( key => !["time", "number", "hoursRange", "project"].includes(key) ).length > 0 ) { const error = new Error("Your measurement file shouldn't contain any extra fields."); error.code = "ERR_FILE_MEASUREMENT_EXTRA_PROPERTIES"; reject(error); } // Force validations of measurement's fields. const {time, number, hoursRange, project} = this; // Additional checks for hoursRange. hoursRange.forEach((hourRange, rangeIndex) => { // Check if from and to fields are in correct order. if (rangeIndex < hoursRange.length - 1) { if ( utils.timeToMinutesNumber( hourRange.to ) > utils.timeToMinutesNumber( hoursRange[rangeIndex + 1].from ) ) { const error = new Error("The range of measured hours of your measurement file has to be in incremental order."); error.code = "ERR_FILE_MEASUREMENT_HOURSRANGE_WRON_ORDER"; reject(error); } } }); // Check if hoursRange has the same amount of time as "time" field. const [fullMinutes, hrMinutes] = [ utils.timeToMinutesNumber(time), utils.timeRangesToMinutesNumber(hoursRange) ]; if (fullMinutes !== hrMinutes) { const error = new Error("The range of measured hours in your measurement file has to be equal the measured time."); error.code = "ERR_FILE_MEASUREMENT_HOURSRANGE_WRONG_RANGES"; reject(error); } // Check if user tries to continue the same measurement next day. if ( utils.timeToMinutesNumber( utils.getCorrectTime() ) < utils.timeToMinutesNumber( hoursRange[hoursRange.length - 1].to ) && this.filePath !== null ) { const error = new Error("Your measured time can't excess 24 hours."); error.code = "ERR_FILE_MEASUREMENT_TOO_LONG_MEASUREMENT"; reject(error); } // Check if measurement made any time ago has been saved. if (this.filePath !== null) { fs.stat(this.filePath, {bigint: false}, (statError, stats) => { if (statError !== null && statError.code === "ENOENT") { resolve(true); } else if (statError === null) { const today = new Date(); if ( stats.birthtime.getFullYear() < today.getFullYear() || stats.birthtime.getMonth() < today.getMonth() || stats.birthtime.getDate() < today.getDate() || ( stats.birthtime.getHours() === 0 && stats.birthtime.getMinutes() === 0 ) ) { const error = new Error(`You haven't saved your previous measurement made on ${this.day} at ${this.hoursRange[this.hoursRange.length - 1].to}.`); error.code = "ERR_FILE_MEASUREMENT_PREVIOUS_MEASUREMENT_NOT_SAVED"; reject(error); } else { resolve(true); } } else { reject(statError); } }); } else { resolve(true); } }); }, writable: false, enumerable: false, configurable: false, }); // Modifies the measurement. Object.defineProperty(TimeMeasurement.prototype, "update", { value: function update({time, number, hoursRange, project}) { return new Promise((resolve, reject) => { const updatedProperties = {time, number, hoursRange, project}; Object.keys(updatedProperties).forEach(key => { if ( updatedProperties[key] !== undefined && updatedProperties[key] !== null && this.fileContent[key] !== updatedProperties[key] ) { this.fileContent[key] = updatedProperties[key]; } }); this.createOrSave() .then(createOrSaveSuccess => resolve(true)) .catch(updateVerificationOrCreationError => { reject(updateVerificationOrCreationError) }); }); }, writable: false, enumerable: false, configurable: false }); // Removes the measurement file. Used in reset command. Object.defineProperty(TimeMeasurement.prototype, "delete", { value: function remove() { return new Promise((resolve, reject) => { fs.unlink(this.filePath, (deleteError) => { if (deleteError !== null && deleteError.code === "ENOENT") { const error = new Error(`You have no measurement file to delete at ${this.filePath}.`); error.code = "ERR_FILE_MEASUREMENT_NOTHING_TO_DELETE"; reject(error); } else if (deleteError === null) { resolve(true); } else { reject(deleteError); } }); }); }, writable: false, enumerable: false, configurable: false }); // Reads and instantiates an existing measurement file. Utilises base // object's load method. Object.defineProperty(TimeMeasurement, "load", { value: function load(pathToJSONFile, validateContent = true) { return new Promise((resolve, reject) => { WorkWatchFile.load(pathToJSONFile, validateContent, TimeMeasurement) .then(fileObject => { if (fileObject instanceof TimeMeasurement) { resolve(fileObject); } }) .catch(fileLoadError => { if (fileLoadError.code === "ENOENT") { const error = new Error(`There is no measurement file at ${pathToJSONFile}.`); error.code = "ERR_FILE_NO_MEASUREMENT"; reject(error); } else { reject(fileLoadError); } }); }); }, writable: false, enumerable: false, configurable: false }); module.exports = TimeMeasurement;