/* * NOTE: * Changes to this file should be kept in sync with * https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/lib/gitlab_chronic_duration.rb. */ /* * This code is based on code from * https://gitlab.com/gitlab-org/gitlab-chronic-duration and is * distributed under the following license: * * MIT License * * Copyright (c) Henry Poydar * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. */ export class DurationParseError extends Error {} // On average, there's a little over 4 weeks in month. const FULL_WEEKS_PER_MONTH = 4; const HOURS_PER_DAY = 24; const DAYS_PER_MONTH = 30; const FLOAT_MATCHER = /[0-9]*\.?[0-9]+/g; const DURATION_UNITS_LIST = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years']; const MAPPINGS = { seconds: 'seconds', second: 'seconds', secs: 'seconds', sec: 'seconds', s: 'seconds', minutes: 'minutes', minute: 'minutes', mins: 'minutes', min: 'minutes', m: 'minutes', hours: 'hours', hour: 'hours', hrs: 'hours', hr: 'hours', h: 'hours', days: 'days', day: 'days', dy: 'days', d: 'days', weeks: 'weeks', week: 'weeks', wks: 'weeks', wk: 'weeks', w: 'weeks', months: 'months', mo: 'months', mos: 'months', month: 'months', years: 'years', year: 'years', yrs: 'years', yr: 'years', y: 'years', }; const JOIN_WORDS = ['and', 'with', 'plus']; function convertToNumber(string) { const f = parseFloat(string); return f % 1 > 0 ? f : parseInt(string, 10); } function durationUnitsSecondsMultiplier(unit, opts) { if (!DURATION_UNITS_LIST.includes(unit)) { return 0; } const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY; const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH; const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH); switch (unit) { case 'years': return 31557600; case 'months': return 3600 * hoursPerDay * daysPerMonth; case 'weeks': return 3600 * hoursPerDay * daysPerWeek; case 'days': return 3600 * hoursPerDay; case 'hours': return 3600; case 'minutes': return 60; case 'seconds': return 1; default: return 0; } } function calculateFromWords(string, opts) { let val = 0; const words = string.split(' '); words.forEach((v, k) => { if (v === '') { return; } if (v.search(FLOAT_MATCHER) >= 0) { val += convertToNumber(v) * durationUnitsSecondsMultiplier( words[parseInt(k, 10) + 1] || opts.defaultUnit || 'seconds', opts, ); } }); return val; } // Parse 3:41:59 and return 3 hours 41 minutes 59 seconds function filterByType(string) { const chronoUnitsList = DURATION_UNITS_LIST.filter((v) => v !== 'weeks'); if ( string .replace(/ +/g, '') .search(RegExp(`${FLOAT_MATCHER.source}(:${FLOAT_MATCHER.source})+`, 'g')) >= 0 ) { const res = []; string .replace(/ +/g, '') .split(':') .reverse() .forEach((v, k) => { if (!chronoUnitsList[k]) { return; } res.push(`${v} ${chronoUnitsList[k]}`); }); return res.reverse().join(' '); } return string; } // Get rid of unknown words and map found // words to defined time units function filterThroughWhiteList(string, opts) { const res = []; string.split(' ').forEach((word) => { if (word === '') { return; } if (word.search(FLOAT_MATCHER) >= 0) { res.push(word.trim()); return; } const strippedWord = word.trim().replace(/^,/g, '').replace(/,$/g, ''); if (MAPPINGS[strippedWord] !== undefined) { res.push(MAPPINGS[strippedWord]); } else if (!JOIN_WORDS.includes(strippedWord) && opts.raiseExceptions) { throw new DurationParseError( `An invalid word ${JSON.stringify(word)} was used in the string to be parsed.`, ); } }); // add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec' if (res.length > 0 && MAPPINGS[res[0]]) { res.splice(0, 0, 1); } return res.join(' '); } function cleanup(string, opts) { let res = string.toLowerCase(); /* * TODO The Ruby implementation of this algorithm uses the Numerizer module, * which converts strings like "forty two" to "42", but there is no * JavaScript equivalent of Numerizer. Skip it for now until Numerizer is * ported to JavaScript. */ res = filterByType(res); res = res .replace(FLOAT_MATCHER, (n) => ` ${n} `) .replace(/ +/g, ' ') .trim(); return filterThroughWhiteList(res, opts); } function humanizeTimeUnit(number, unit, pluralize, keepZero) { if (number === '0' && !keepZero) { return null; } let res = number + unit; // A poor man's pluralizer if (number !== '1' && pluralize) { res += 's'; } return res; } // Given a string representation of elapsed time, // return an integer (or float, if fractions of a // second are input) export function parseChronicDuration(string, opts = {}) { const result = calculateFromWords(cleanup(string, opts), opts); return !opts.keepZero && result === 0 ? null : result; } // Given an integer and an optional format, // returns a formatted string representing elapsed time export function outputChronicDuration(seconds, opts = {}) { const units = { years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds, }; const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY; const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH; const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH); const decimalPlaces = seconds % 1 !== 0 ? seconds.toString().split('.').reverse()[0].length : null; const minute = 60; const hour = 60 * minute; const day = hoursPerDay * hour; const month = daysPerMonth * day; const year = 31557600; if (units.seconds >= 31557600 && units.seconds % year < units.seconds % month) { units.years = Math.trunc(units.seconds / year); units.months = Math.trunc((units.seconds % year) / month); units.days = Math.trunc(((units.seconds % year) % month) / day); units.hours = Math.trunc((((units.seconds % year) % month) % day) / hour); units.minutes = Math.trunc(((((units.seconds % year) % month) % day) % hour) / minute); units.seconds = Math.trunc(((((units.seconds % year) % month) % day) % hour) % minute); } else if (seconds >= 60) { units.minutes = Math.trunc(seconds / 60); units.seconds %= 60; if (units.minutes >= 60) { units.hours = Math.trunc(units.minutes / 60); units.minutes = Math.trunc(units.minutes % 60); if (!opts.limitToHours) { if (units.hours >= hoursPerDay) { units.days = Math.trunc(units.hours / hoursPerDay); units.hours = Math.trunc(units.hours % hoursPerDay); if (opts.weeks) { if (units.days >= daysPerWeek) { units.weeks = Math.trunc(units.days / daysPerWeek); units.days = Math.trunc(units.days % daysPerWeek); if (units.weeks >= FULL_WEEKS_PER_MONTH) { units.months = Math.trunc(units.weeks / FULL_WEEKS_PER_MONTH); units.weeks = Math.trunc(units.weeks % FULL_WEEKS_PER_MONTH); } } } else if (units.days >= daysPerMonth) { units.months = Math.trunc(units.days / daysPerMonth); units.days = Math.trunc(units.days % daysPerMonth); } } } } } let joiner = opts.joiner || ' '; let process = null; let dividers; switch (opts.format) { case 'micro': dividers = { years: 'y', months: 'mo', weeks: 'w', days: 'd', hours: 'h', minutes: 'm', seconds: 's', }; joiner = ''; break; case 'short': dividers = { years: 'y', months: 'mo', weeks: 'w', days: 'd', hours: 'h', minutes: 'm', seconds: 's', }; break; case 'long': dividers = { /* eslint-disable @gitlab/require-i18n-strings */ years: ' year', months: ' month', weeks: ' week', days: ' day', hours: ' hour', minutes: ' minute', seconds: ' second', /* eslint-enable @gitlab/require-i18n-strings */ pluralize: true, }; break; case 'chrono': dividers = { years: ':', months: ':', weeks: ':', days: ':', hours: ':', minutes: ':', seconds: ':', keepZero: true, }; process = (str) => { // Pad zeros // Get rid of lead off times if they are zero // Get rid of lead off zero // Get rid of trailing: const divider = ':'; const processed = []; str.split(divider).forEach((n) => { if (n === '') { return; } // add zeros only if n is an integer if (n.search('\\.') >= 0) { processed.push( parseFloat(n) .toFixed(decimalPlaces) .padStart(3 + decimalPlaces, '0'), ); } else { processed.push(n.padStart(2, '0')); } }); return processed .join(divider) .replace(/^(00:)+/g, '') .replace(/^0/g, '') .replace(/:$/g, ''); }; joiner = ''; break; default: dividers = { /* eslint-disable @gitlab/require-i18n-strings */ years: ' yr', months: ' mo', weeks: ' wk', days: ' day', hours: ' hr', minutes: ' min', seconds: ' sec', /* eslint-enable @gitlab/require-i18n-strings */ pluralize: true, }; break; } let result = []; ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'].forEach((t) => { if (t === 'weeks' && !opts.weeks) { return; } let num = units[t]; if (t === 'seconds' && num % 0 !== 0) { num = num.toFixed(decimalPlaces); } else { num = num.toString(); } const keepZero = !dividers.keepZero && t === 'seconds' ? opts.keepZero : dividers.keepZero; const humanized = humanizeTimeUnit(num, dividers[t], dividers.pluralize, keepZero); if (humanized !== null) { result.push(humanized); } }); if (opts.units) { result = result.slice(0, opts.units); } result = result.join(joiner); if (process) { result = process(result); } return result.length === 0 ? null : result; }