summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/chronic_duration.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/chronic_duration.js')
-rw-r--r--app/assets/javascripts/chronic_duration.js417
1 files changed, 417 insertions, 0 deletions
diff --git a/app/assets/javascripts/chronic_duration.js b/app/assets/javascripts/chronic_duration.js
new file mode 100644
index 00000000000..1073d736b06
--- /dev/null
+++ b/app/assets/javascripts/chronic_duration.js
@@ -0,0 +1,417 @@
+/*
+ * 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;
+}