User:Gary/subjects age from year.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/*
 * decaffeinate suggestions:
 * DS101: Remove unnecessary use of Array.from
 * DS102: Remove unnecessary code created because of implicit returns
 * DS202: Simplify dynamic range loops
 * DS205: Consider reworking code to avoid use of IIFEs
 * DS206: Consider reworking classes to avoid initClass
 * DS207: Consider shorter variations of null checks
 * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
 */
/*
  SUBJECT AGE FROM YEAR
  Description: In an article about a person or a company, when the mouse hovers
  over a year in the article, the age of the article's subject by that year
  appears in a tooltip.
*/
var SubjectAgeFromYear = (function() {
  let now = undefined;
  SubjectAgeFromYear = class SubjectAgeFromYear {
    static initClass() {
      now = new Date();
    }

    static extractYearFromText({
      yearIndex,
      patternIndex,
      $newNode,
      nodeText,
      subjectYear,
      years,
    }) {
      let $abbr;
      const abbrText = years[yearIndex];
      let currentYear = years[yearIndex];
      const birthYearIndex = nodeText.indexOf(currentYear);
      let workThisYear = true;

      // don't work on this year-for AD years
      if (
        patternIndex === 0 &&
        // 'year' is followed by a ' BC'; wait for next pattern to work on this
        (nodeText.substr(birthYearIndex + currentYear.length, 3).indexOf('BC') >
          -1 ||
          // 'year' is preceded by a ','; this is probably a unit such as 1,000 km
          nodeText.substr(birthYearIndex - 1, 1).indexOf(',') > -1 ||
          // 'year' is preceded by a month; this is probably part of a day,
          // like "January 1"
          ((currentYear.length <= 2 &&
            (this.nearAMonth(nodeText, birthYearIndex, -1, years, yearIndex) &&
              currentYear.indexOf('AD') === -1)) ||
            // 'year' is followed by a month; this is probably part of a day,
            // like "January 1"
            this.nearAMonth(
              nodeText,
              birthYearIndex + currentYear.length,
              1
            )) ||
          // 'year' is followed by "?year", such as "-year", " years"
          nodeText
            .substr(birthYearIndex + currentYear.length, 5)
            .indexOf('year') > -1)
      ) {
        workThisYear = false;
      }

      // After the following conditionals, currentYear will be converted from a
      // STRING (which possibly holds BC/AD) to an INTEGER
      // currentYear contains "BC" somewhere
      currentYear =
        currentYear.indexOf('BC') > -1 ||
        ((subjectYear.birthYear() < 0 || subjectYear.deathYear() < 0) &&
          nodeText
            .substr(birthYearIndex + currentYear.length + ' BC'.length, 10)
            .indexOf('BC') > -1)
          ? -1 * parseInt(currentYear)
          : // currentYear contains "AD" somewhere
            currentYear.indexOf('AD') > -1 || currentYear.indexOf('CE') > -1
            ? parseInt(currentYear.replace(/AD/, '').replace(/CE/, ''))
            : // currentYear does not contain "BC" or "AD"
              parseInt(currentYear);

      const firstPart = nodeText.substring(0, birthYearIndex);

      // Subtract one year from difference if it spans year zero
      const difference =
        (subjectYear.birthYear() < 0 && 0 < currentYear) ||
        (subjectYear.birthYear() > 0 && 0 > currentYear)
          ? currentYear - subjectYear.birthYear() - 1
          : currentYear - subjectYear.birthYear();

      // find a year to act on; work on AD years first, then BC years
      const condition =
        workThisYear &&
        (currentYear >= subjectYear.birthYear() ||
          currentYear >=
            subjectYear.birthYear() - subjectYear.birthYearBuffer()) &&
        (currentYear <= subjectYear.deathYear() ||
          currentYear <=
            subjectYear.deathYear() + subjectYear.birthYearBuffer());

      //#
      // Create the hover with an ABBR tag.
      if (condition) {
        $abbr = $('<abbr class="subject-age-from-year"></abbr>');

        const currentYearYearsAgo = now.getFullYear() - currentYear;
        const currentYearYearsAgoText =
          currentYearYearsAgo > 0
            ? `${this.pluralize('year', currentYearYearsAgo, true)} ago`
            : currentYearYearsAgo < 0
              ? `${this.pluralize('year', currentYearYearsAgo, true)} from now`
              : 'this year';

        // after death year but before the buffer
        if (
          currentYear > subjectYear.deathYear() &&
          currentYear <= subjectYear.deathYear() + subjectYear.birthYearBuffer()
        ) {
          const yearsLater = currentYear - subjectYear.deathYear();
          $abbr.attr(
            'title',
            `${this.pluralize('year', yearsLater, true)} after \
${subjectYear.phrase('death')}`
          );
          // was alive at currentYear
        } else if (difference >= 0) {
          // age at currentYear
          $abbr.attr(
            'title',
            `${this.pluralize('year', difference, true)} old`
          );

          // birth year
          if (difference === 0) {
            const currentAge =
              subjectYear.type() === 'biography' && subjectYear.isAlive()
                ? `; now ${now.getFullYear() -
                    subjectYear.birthYear()} years old`
                : '';

            // Add the person's current age.
            $abbr.attr(
              'title',
              `${$abbr.attr('title')} \
(${subjectYear.phrase('birth')}${currentAge})`
            );
            // death year
          } else if (currentYear === subjectYear.deathYear()) {
            $abbr.attr(
              'title',
              `${$abbr.attr('title')} \
(${subjectYear.phrase('death')})`
            );
          }
          // currentYear is before birth year
        } else {
          const absoluteDifference = Math.abs(difference);
          $abbr.attr(
            'title',
            `${this.pluralize('year', absoluteDifference, true)} \
before ${subjectYear.phrase('birth')}`
          );
        }

        // Add a note indicating how far away from now is the year.
        if ($abbr.attr('title').indexOf(' now ') === -1) {
          $abbr.attr(
            'title',
            `${$abbr.attr('title')} \
(${currentYearYearsAgoText})`
          );
        }
        // Add the existing number from the page's text as the ABBR's text.
        $abbr.append(abbrText);
      } else {
        $abbr = '';
      }

      // Append the new ABBR if we found a year we could work with; otherwise,
      // just add the old text content back in.
      $newNode.append(firstPart).append($abbr.length ? $abbr : abbrText);

      // after the year, only for the last occurrence of a year in a node
      if (yearIndex + 1 === years.length) {
        const secondPart = nodeText.substring(birthYearIndex + abbrText.length);
        $newNode.append(secondPart);
      }

      // This is used for when the loop rolls around again.
      nodeText = nodeText.substring(birthYearIndex + abbrText.length);

      return {
        yearIndex,
        patternIndex,
        $newNode,
        nodeText,
        subjectYear,
        years,
      };
    }

    static findYearsInText({
      patternIndex,
      $node,
      patterns,
      spansToRemove,
      subjectYear,
    }) {
      if ($node[0].nodeType !== 3) {
        return true;
      }

      let nodeText = $node[0].nodeValue;
      let years = nodeText.match(patterns[patternIndex]);

      if (years == null) {
        return true;
      }

      const minBirthYearBuffer = 100;
      const age = subjectYear.deathYear() - subjectYear.birthYear();

      subjectYear.birthYearBuffer(
        age >= minBirthYearBuffer && subjectYear.type() === 'biography'
          ? age
          : minBirthYearBuffer
      );

      let $newNode = $('<span></span>');

      // loop through each year in the same text node
      for (
        let i = 0, yearIndex = i, end = years.length, asc = 0 <= end;
        asc ? i < end : i > end;
        asc ? i++ : i--, yearIndex = i
      ) {
        ({
          yearIndex,
          patternIndex,
          $newNode,
          nodeText,
          subjectYear,
          years,
        } = this.extractYearFromText({
          yearIndex,
          patternIndex,
          $newNode,
          nodeText,
          subjectYear,
          years,
        }));
      }

      if ($newNode.contents().length > 0) {
        $node.replaceWith($newNode);
        return spansToRemove.push($newNode);
      }
    }

    static findMatchesinCategory({
      allBirthYears,
      allDeathYears,
      birthYear,
      deathYear,
      matches,
      type,
    }) {
      // Set ordered match results to actual variable names.
      let categoryYear = matches[0];
      const categoryType = matches[1];

      // Set the category's year to be negative if it's a BC year.
      categoryYear =
        categoryYear.indexOf('BC') > -1
          ? -1 * parseInt(categoryYear)
          : parseInt(categoryYear);

      // If type hasn't already been set to "biography", then check to see if it
      // should. "Biography" type takes precendence over "establishment" type. We
      // have to check for every category if it indicates that the type is actually
      // a biography.
      if (type !== 'biography') {
        type = (categoryType != null
        ? categoryType.match(/(births|deaths)/)
        : undefined)
          ? 'biography'
          : 'establishment';
      }

      // Birth years
      if (
        !(categoryType != null
          ? categoryType.match(/(disestablishments|deaths|disestablished)/)
          : undefined) &&
        ((type === 'biography' && categoryType === 'births') ||
          type !== 'biography')
      ) {
        birthYear = categoryYear;
        allBirthYears.push(birthYear);
        // Death years
      } else {
        // Only continue if type is "biography" and category is a "death year", or
        // type is "establishment".
        if (
          (type === 'biography' && categoryType === 'deaths') ||
          type === 'establishment'
        ) {
          deathYear = categoryYear;
          allDeathYears.push(deathYear);
        }
      }

      return {
        allBirthYears,
        allDeathYears,
        birthYear,
        deathYear,
        matches,
        type,
      };
    }

    static findYearFromCategory({
      allBirthYears,
      allDeathYears,
      allMatches,
      birthYear,
      category,
      deathYear,
      type,
    }) {
      // Format: [pattern<RegExp>, order<Array>].
      // The order should always be: [<year>, <type>].
      const patterns = [
        // Special cases: a four-digit year, followed by a capitalized term
        //   E.g. 1980 Oscar winners
        [/^([0-9]{4,4})\s([\w\s]+)$/, [1, 2]],
        // E.g. 950 BC
        [/^([0-9]{1,4}(\sBC)?)$/, [1]],
        // Match a year at the start, with optionally the word "BC" at the end.
        //   E.g. 123 BC births; 1950 establishments
        [/^([0-9]{1,4}(\sBC)?)\s([A-Za-z\s]+)$/, [1, 3]],
        // E.g. Establishments in 1925
        [/^(.*?)\s(in|for)\s([0-9]{1,4}(\sBC)?)$/, [3, 1]],
      ];

      // Match the patterns to the category.
      let matches = [];

      for (let pattern of Array.from(patterns)) {
        const matched = category.match(pattern[0]);

        if (matched) {
          for (let order of Array.from(pattern[1])) {
            matches.push(matched[order]);
          }

          break;
        }
      }

      // There is a match
      if (matches.length > 0) {
        allMatches.push(category);

        ({
          allBirthYears,
          allDeathYears,
          birthYear,
          deathYear,
          matches,
          type,
        } = this.findMatchesinCategory({
          allBirthYears,
          allDeathYears,
          birthYear,
          deathYear,
          matches,
          type,
        }));
      }

      return {
        allBirthYears,
        allDeathYears,
        allMatches,
        birthYear,
        category,
        deathYear,
        type,
      };
    }

    static findYearsFromCategories() {
      let birthYear, deathYear, type;
      let category;
      let allBirthYears = [];
      let allDeathYears = [];
      let allMatches = [];

      const categories = (() => {
        const result = [];
        for (category of Array.from(window.mw.config.get('wgCategories'))) {
          result.push(category.replace(/_/g, ' '));
        }
        return result;
      })();

      for (category of Array.from(categories)) {
        ({
          allBirthYears,
          allDeathYears,
          allMatches,
          birthYear,
          category,
          deathYear,
          type,
        } = this.findYearFromCategory({
          allBirthYears,
          allDeathYears,
          allMatches,
          birthYear,
          category,
          deathYear,
          type,
        }));
      }

      // Show which category was matched for birth/death dates. Use a special
      // object for this so I can set defaults without changing the original
      // variable.
      const catText = { type, birthYear, deathYear, allMatches };

      if (!catText['type']) {
        catText['type'] = 'establishment';
      }

      if (!catText['birthYear']) {
        catText['birthYear'] = '(none)';
      }

      if (!catText['deathYear']) {
        catText['deathYear'] = '(none)';
      }

      if (!catText['allMatches']) {
        catText['allMatches'] = '(none)';
      }

      catText.allMatches = catText.allMatches.map((value) => `- ${value}`);

      $('#catlinks').attr(
        'title',
        `Type: ${catText.type}\nBirth year: \
${catText.birthYear}\nDeath year: ${catText.deathYear}\n\nMatched \
categories:\n\n${catText.allMatches.join('\n')}`
      );

      return { allBirthYears, allDeathYears, birthYear, deathYear, type };
    }

    static init() {
      const wgCNamespace = window.mw.config.get('wgCanonicalNamespace');
      const wgAction = window.mw.config.get('wgAction');
      const wgPageName = window.mw.config.get('wgPageName');

      if (
        (wgCNamespace !== '' ||
          window.mw.util.getParamValue('disable') === 'age' ||
          wgAction !== 'view') &&
        !(
          wgPageName === 'User:Gary/Sandbox' &&
          (wgAction === 'view' || wgAction === 'submit')
        )
      ) {
        return false;
      }

      // Check if there are any categories.
      if (window.mw.config.get('wgCategories') === null) {
        return false;
      }

      let {
        allBirthYears,
        allDeathYears,
        birthYear,
        deathYear,
        type,
      } = this.findYearsFromCategories();

      // We can't continue without a birth year
      if (birthYear == null) {
        return false;
      }

      // Sort birth years. They will be sorted again, with some removed, later as
      // well.
      allBirthYears.sort(function(a, b) {
        if (a < b) {
          return -1;
        } else if (a > b) {
          return 1;
        } else {
          return 0;
        }
      });

      // Do death year first, so we can ensure the birth year comes before the
      // death year
      //
      // Return the death year that is closest to today's year, without going past
      // it
      if (allDeathYears.length > 1) {
        allDeathYears.sort(function(a, b) {
          const aYearsAgo = now.getFullYear() - a;
          const bYearsAgo = now.getFullYear() - b;

          if (aYearsAgo < 0) {
            return 1;
          } else if (bYearsAgo < 0) {
            return -1;
          } else {
            return aYearsAgo - bYearsAgo;
          }
        });

        deathYear = allDeathYears[0];
        // There are no death years, but there are at least two birth years, so one
        // of them could possibly be a death year. Do this only for BC years because
        // they are particularly problematic, since they only use categories like:
        // "15 BC" and then "10s BC deaths".
      } else if (
        allDeathYears.length === 0 &&
        allBirthYears.length >= 2 &&
        allBirthYears[0] < 0 &&
        allBirthYears[1] < 0
      ) {
        // Set the birth year as the first year.
        birthYear = allBirthYears[0];

        // Remove the second birth year and set it as the death year.
        deathYear = allBirthYears.splice(1, 1)[0];

        // Set the type as a biography, because we got at least two years that
        // are BC.
        type = 'biography';
      }

      // Do birth years
      //
      // Return a birth year that is before the death year, and also closest
      // to today's year.
      if (allBirthYears.length > 1) {
        allBirthYears.sort(function(a, b) {
          if (deathYear != null) {
            const aDeathDiff = deathYear - a;
            const bDeathDiff = deathYear - b;

            if (aDeathDiff < 0) {
              return 1;
            } else if (bDeathDiff < 0) {
              return -1;
            } else {
              return aDeathDiff - bDeathDiff;
            }
          } else {
            const aYearsAgo = now.getFullYear() - a;
            const bYearsAgo = now.getFullYear() - b;

            if (aYearsAgo < 0) {
              return 1;
            } else if (bYearsAgo < 0) {
              return -1;
            } else {
              return aYearsAgo - bYearsAgo;
            }
          }
        });

        birthYear = allBirthYears[0];
      }

      // "isAlive" is only used for people, not establishments
      const subjectYear = new SubjectYear();
      subjectYear.type(type);
      subjectYear.isAlive(false);

      // The maximum possible age for each type.
      const maxPossibleAge = (() => {
        if (subjectYear.type() === 'biography') {
          return 125;
        } else if (subjectYear.type() === 'establishment') {
          return 1000;
        }
      })();

      // No death year is available, so logically determine if the person
      // could possibly be alive right now
      if (deathYear == null) {
        deathYear = birthYear + maxPossibleAge;

        if (deathYear >= now.getFullYear()) {
          subjectYear.isAlive(true);
        }
      }

      const spansToRemove = [];
      const patterns = [];
      const birthYearLength = Math.abs(birthYear).toString().length;
      const deathYearLength = Math.abs(deathYear).toString().length;
      const todayLength = now.getFullYear().toString().length;

      const yearLength =
        birthYear < 0 && deathYear > 0
          ? 1
          : birthYearLength < deathYearLength
            ? birthYearLength
            : deathYearLength;

      patterns.push(
        new RegExp(
          `(AD |AD\u00A0)?\\b[0-9]{${yearLength},` +
            todayLength +
            '}\\b( AD|\u00A0AD| CE|\u00A0CE)?',
          'g'
        )
      ); // AD years

      if (birthYear < 0) {
        // BC years
        patterns.push(
          new RegExp(
            `\\b[0-9]{${yearLength},${todayLength}` + '}( |\u00A0)?BC[E]?\\b',
            'g'
          )
        );
      }

      const $allParagraphs = $(
        wgAction === 'submit' ? '#wikiPreview' : '#bodyContent'
      ).find('> div > p, > div > div > p');

      // Set the subject's birth and death years
      subjectYear.birthYear(birthYear);
      subjectYear.deathYear(deathYear);

      // loop through each pattern to find
      return (() => {
        const result = [];
        for (
          var patternIndex = 0, end = patterns.length, asc = 0 <= end;
          asc ? patternIndex < end : patternIndex > end;
          asc ? patternIndex++ : patternIndex--
        ) {
          // loop through each paragraph
          // then loop through each text node in each paragraph
          $allParagraphs.each((index, element) => {
            return $(element)
              .contents()
              .each((index, element) => {
                return this.findYearsInText({
                  patternIndex,
                  $node: $(element),
                  patterns,
                  spansToRemove,
                  subjectYear,
                });
              });
          });

          // remove SPANs from spansToRemove, and merge children with parent
          result.push(
            (() => {
              const result1 = [];
              for (var span of Array.from(spansToRemove)) {
                const children = span.contents();
                const parent = span.parent();

                if (!parent.length) {
                  continue;
                }

                children.each(function(index, element) {
                  const $child = $(element);
                  return span.before($child.clone());
                });

                span.remove();
                result1.push(parent[0].normalize());
              }
              return result1;
            })()
          );
        }
        return result;
      })();
    }

    static nearAMonth(text, startIndex, beforeOrAfter, years, yearIndex) {
      let match;
      if (beforeOrAfter == null) {
        beforeOrAfter = 1;
      }
      const monthsArray = [
        'January',
        'February',
        'March',
        'April',
        'May',
        'June',
        'July',
        'August',
        'September',
        'October',
        'November',
        'December',
      ];
      const pattern = new RegExp(monthsArray.join('|'));

      if (beforeOrAfter === 1) {
        // find the word immediately following the startIndex
        text = text.substring(startIndex, text.length);
        match = text.match(pattern);

        // is this match only a few characters ahead of startIndex?
        if (match && text.indexOf(match[0]) === ' '.length) {
          return true;
        } else {
          return false;
        }
      } else if (beforeOrAfter === -1) {
        // first check if after the current year,
        // there is NO ", nextYearIteration"
        if (
          years[yearIndex + 1] &&
          startIndex + years[yearIndex].length + ', '.length !==
            text.indexOf(years[yearIndex + 1])
        ) {
          return false;
        }

        text = text.substring(0, startIndex);
        match = text.match(pattern);

        if (
          match &&
          text.indexOf(match[0]) === startIndex - ' '.length - match[0].length
        ) {
          return true;
        } else {
          return false;
        }
      }
    }

    static pluralize(word, count, includeCount) {
      if (includeCount == null) {
        includeCount = false;
      }
      const includedCount = includeCount ? `${count} ` : '';

      if (count === 1) {
        return includedCount + word;
      } else {
        return includedCount + word + 's';
      }
    }
  };
  SubjectAgeFromYear.initClass();
  return SubjectAgeFromYear;
})();

class SubjectYear {
  birthYear(birthYearValue) {
    if (birthYearValue == null) {
      ({ birthYearValue } = this);
    }
    this.birthYearValue = birthYearValue;
    return this.birthYearValue;
  }
  birthYearBuffer(birthYearBufferValue) {
    if (birthYearBufferValue == null) {
      ({ birthYearBufferValue } = this);
    }
    this.birthYearBufferValue = birthYearBufferValue;
    return this.birthYearBufferValue;
  }
  deathYear(deathYearValue) {
    if (deathYearValue == null) {
      ({ deathYearValue } = this);
    }
    this.deathYearValue = deathYearValue;
    return this.deathYearValue;
  }
  isAlive(isAliveValue) {
    if (isAliveValue == null) {
      ({ isAliveValue } = this);
    }
    this.isAliveValue = isAliveValue;
    return this.isAliveValue;
  }

  phrase(phrase) {
    phrase = phrase.toLowerCase();
    const phrases = {
      biography: {
        birth: 'birth',
        death: 'death',

        alive: 'alive',
        dead: 'dead',
      },
      establishment: {
        birth: 'established',
        death: 'disestablished',

        alive: 'established',
        dead: 'disestablished',
      },
    };

    if (
      this.typeValue == null ||
      phrases[this.typeValue] == null ||
      phrases[this.typeValue][phrase] == null
    ) {
      return false;
    }

    return phrases[this.typeValue][phrase];
  }

  type(typeValue) {
    if (typeValue == null) {
      ({ typeValue } = this);
    }
    this.typeValue = typeValue;
    return (this.typeValue = this.typeValue.toLowerCase());
  }
}

$(() => SubjectAgeFromYear.init());