User:YMS/orc.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
  Open-ended Recent Changes tool [ORC]
  @author Yannick M. Schmitt, [[User:YMS]] (at Wikimedia projects)

  Documentation, important notes & contact at
  https://meta.wikimedia.org/wiki/User:YMS/ORC

  <nowiki>
 */
(function() {
  "use strict";

  /**
   * HTML class names
   * Those starting with "mw" are pre-defined by MediaWiki or another dependency and the actual values should not be changed
   * Those ending with underscores (_) will be concatenated with some computed values
   */
  const cls = {
    areaAll: "orc-all",
    areaLog: "orc-log",
    areaRc: "orc-rc",
    areaUserRc: "orc-user",
    buttonPatrol: "orc-button-patrol",
    buttonSmall: "orc-button-small",
    buttons: "orc-buttons",
    buttonsNav: "orc-buttons-nav",
    buttonsSettings: "orc-buttons-settings",
    colLogMsg: "orc-col-log-msg",
    colLogRevisionDiff: "orc-col-log-revision-diff",
    colLogTimestamp: "orc-col-log-timestamp",
    colLogTitle: "orc-col-log-title",
    colLogType: "orc-col-log-type",
    colLogUser: "col-log-user",
    colLogWikidataLabel: "col-log-label",
    colRcPageActions: "orc-col-rc-page-patrol",
    colRcPageContentPreview: "orc-col-rc-page-content-preview",
    colRcPageDiff: "orc-col-rc-page-diff",
    colRcPageFlags: "orc-col-rc-page-flags",
    colRcPageTitle: "orc-col-rc-page-title",
    colRcPageWikidataLabel: "orc-col-rc-page-labels",
    colRcPageWikidataSitelinks: "orc-col-rc-page-sitelinks",
    colRcPageWikidataStatements: "orc-col-rc-page-statements",
    colRcRevActionMore: "orc-col-rc-edit-more",
    colRcRevActionPatrol: "orc-col-rc-edit-patrol",
    colRcRevActionRevert: "orc-col-rc-edit-revert",
    colRcRevDiff: "orc-col-rc-edit-diff",
    colRcRevFlags: "orc-col-rc-edit-flags",
    colRcRevTimestamp: "orc-col-rc-edit-timestamp",
    colRcRevUser: "orc-col-rc-edit-user",
    entityAliasLang: "orc-alias-lang",
    entityAliasText: "orc-alias-text",
    entityDescLang: "orc-desc-lang",
    entityDescText: "orc-desc-text",
    entityLabelLang: "orc-label-lang",
    entityLabelText: "orc-label-text",
    entityLabelsMore: "orc-labels-more",
    entityLabelsTable: "orc-labels-table",
    entityLangForeign: "orc-lang-foreign",
    entityLangOwn: "orc-lang-own",
    entityNoText: "orc-notext",
    flagsTags: "orc-flagstags",
    flagsTagsHighlightFlag: "orc-flagstags-highlight-flag",
    flagsTagsHighlightTag: "orc-flagstags-highlight-tag",
    help: "orc-help",
    helpContact: "orc-help-contact",
    helpImageCredit: "orc-help-imagecredits",
    infoQuery: "orc-info-query",
    infoSection: "orc-info",
    infoTime: "orc-info-time",
    infoTimeStart: "orc-info-time-start",
    loaded: "orc-loaded",
    loading: "orc-loading",
    loadingEdited: "orc-loading-edited",
    logTableBody: "orc-log-table-body",
    logTypeError: "orc-log-type-error",
    logTypeHidden: "orc-log-type-hidden",
    logTypeInfo: "orc-log-type-info",
    logTypePatrol: "orc-log-type-patrol",
    logTypeRemember: "orc-log-type-remember",
    logTypeUndo: "orc-log-type-undo",
    logTypeVerbose: "orc-log-type-verbose",
    mwComment: "comment",
    mwDiff: "diff",
    mwDiffContentAlignLeft: "diff-contentalign-left",
    mwOoButtonElement: "oo-ui-buttonElement-button",
    mwOoButtonElementFramed: "oo-ui-buttonElement-framed",
    mwOoLabelElement: "oo-ui-labelElement",
    mwOoLabelElementLabel: "oo-ui-labelElement-label",
    mwRcFilters: "rcfilters-head",
    mwRcOverlay: "mw-rcfilters-ui-overlay",
    mwSavedQueryTitle: "mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle",
    oresRating: "orc-ores_",
    pageContainer: "orc-page-container",
    patrolMark: "orc-patrol",
    patrolMarkAll: "orc-patrol-all",
    rcTableBody: "orc-rc-table-body",
    rcTableHead: "orc-rc-table-head",
    rcTableHidePatrolled: "orc-rc-table-hidepatrolled",
    statementDescriptionText: "orc-statement-desc-text",
    timestampDate: "orc-timestamp-date"
  };

  /**
   * HTML element IDs
   * Those starting with "id" will be concatenated with some ID, the other ones should be unique without suffix
   */
  const ids = {
    idPageContentPreview: "orc-page-content-preview-id_",
    idPageLabel: "orc-page-label-id_",
    idPageSitelink: "orc-page-sitelink-id_",
    idPageSitelinkPreviewButton: "orc-page-sitelink-preview-button-id_",
    idPageSitelinkPreviewContent: "orc-rev-sitelink-preview-content-id_",
    idPageStatement: "orc-page-statement-id_",
    idPageTable: "orc-page-table-id_",
    idRevDiffPreview: "orc-rev-diff-preview-id_",
    idRevPatrolState: "orc-rev-patrol-state-id_",
    idRevTable: "orc-rev-table-id_",
    mainDiv: "orc-main-div",
    overlayLoading: "orc-overlay-loading",
    overlayPopup: "orc-overlay-popup",
    rcTab: "ca-orc"
  };

  // TODO: Define table column widths: Some with max-width: 123 ch; to prevent them getting to big, rest with percentages adding up to 100% (will be adjusted by browser); also check out https://developer.mozilla.org/en-US/docs/Web/CSS/calc
  /** CSS style definitions */
  const orcCss = `
    /* Color definitions */

    html {
      --color-bg-page: #FFFFFF;
      --color-bg-flag-highlight: #00FF00;
      --color-bg-tag-highlight-c1: #c2d1f0;
      --color-bg-tag-highlight-c2: #b3e7dc;
      --color-bg-tag-highlight-c3: #fff0c2;
      --color-bg-tag-highlight-c4: #ffd3bd;
      --color-bg-tag-highlight-c5: #f5c2c2;
      --color-bg-lang-foreign: #fff0c2;
      --color-bg-loading: #FFFFFF;
      --color-bg-row-rev-dark: #F2F2F2;
      --color-bg-row-rev-bright: #FFFFFF;
      --color-bg-row-page-dark: #BBBBBB;
      --color-bg-row-page-bright: #CCCCCC;
      --color-bg-row-page-brighttext: #555555;
      --color-txt-error: #FF0000;
      --color-allpatrolled: #0000FF;
      --color-border: #000000;
    }


    /* Overall page layout */

    * {
      text-size-adjust: none;
    }

    html {
      height: 100%;
    }

    body {
      background: var(--color-bg-page);
      padding: 0;
      margin: 0;
      font-size: 100%;
      height: 100%;

      /* Only make the content scrollable (not the whole page) to prevent Chrome Mobile & Co from autohiding the address bar, causing resizing/repositioning problems */
      overflow-y: hidden;
    }

    body > div {
      height: 100%;

      /* Force vertical scrollbar to avoid jumping layout */
      overflow-y: scroll;
    }

    .${cls.pageContainer} {
      padding: 0;
      margin: 0;
      list-style: none;
      display: -webkit-box;
      display: -moz-box;
      display: -ms-flexbox;
      display: -webkit-flex;
      display: flex;
      -webkit-flex-flow: row;
      justify-content: space-around;
    }

    ul.${cls.pageContainer} li {
      margin: 5px;
      text-align: center;
      align-self: center;
    }

    table {
      margin: auto;
      width: 100%;
      border-collapse: collapse;
    }

    div.${cls.areaAll} {
      max-width: 1500px;
      height: 100%;
      border: 1px solid;
      margin: auto;
    }

    div.${cls.areaLog} {
      border-bottom: 1px solid;
      padding: 2px;
      resize: vertical; /* TODO: Log resize not working on Chrome Mobile -> offer height setting in the settings? */
      overflow: auto;
      height: 25vh;
      font-size: 90%;
    }

    div.${cls.areaRc} {
      position: relative;
      padding: 2px;
    }

    div.${cls.areaRc}.${cls.loading} {
      filter: blur(1px) opacity(85%);
    }

    div.${cls.areaRc}.${cls.loading}.${cls.loadingEdited} {
      filter: blur(1px) opacity(85%) grayscale(100%);
    }

    .${cls.rcTableBody} > tr, .${cls.logTableBody} > tr {
      background-image: linear-gradient(to bottom, var(--color-bg-row-rev-bright) 0, var(--color-bg-row-rev-dark) 100%);
    }

    .${cls.rcTableBody} td, .${cls.logTableBody} td {
      padding-left: 5px;
    }

    .${cls.areaRc} > thead > tr {
      background-image: linear-gradient(to bottom, var(--color-bg-row-page-bright) 0, var(--color-bg-row-page-dark) 100%);

      /* Try to reduce changing of height after label and other content is loaded */
      height: 10ex;
    }

    .${cls.areaRc} > tbody > tr {
      /* Try to reduce changing of height after diff and other content is loaded */
      height: 13ex;
    }

    td {
      vertical-align: middle;
    }


    /* Table columns and contents */

    .${cls.colRcRevTimestamp} {
      text-align: center;
      width: 20ch;
    }

    .${cls.colRcRevUser} {
      text-align: center;
      width: 30ch;
      word-break: break-all;
    }

    .${cls.colRcRevDiff} {
      max-width: 200ch;
    }

    .${cls.colRcRevFlags} {
      text-align: center;
      width: 30ch;
    }

    .${cls.colRcRevActionPatrol} {
      text-align: center;
      width: 8ch;
    }

    .${cls.colRcRevActionRevert} {
      text-align: center;
      width: 8ch;
    }

    .${cls.colRcRevActionMore} {
      text-align: center;
      width: 8ch;
    }

    .${cls.colLogTimestamp} {
      text-align: center;
      width: 20ch;
    }

    .${cls.colLogType} {
      text-align: center;
      width: 20ch;
    }

    .${cls.colLogMsg} {
      max-width: 200ch;
      min-width: 20ch;
    }

    .${cls.colLogTitle} {
      text-align: center;
      width: 30ch;
    }

    .${cls.colLogWikidataLabel} {
      text-align: center;
      width: 40ch;
    }

    .${cls.colLogRevisionDiff} {
      width: 40ch;
    }

    .${cls.colLogUser} {
      text-align: center;
      width: 30ch;
    }

    .${cls.entityLabelText} {
      font-weight: bold;
    }

    .${cls.entityLabelLang}, .${cls.entityDescLang} {
      margin-left: 10px;
    }

    .${cls.entityLangForeign} {
      font-weight: bold; font-style: italic; font-size: 90%;
      background: var(--color-bg-lang-foreign);
    }

    .${cls.entityLangOwn}, .${cls.entityAliasLang} {
      display: none;
    }

    .${cls.areaLog} .${cls.entityAliasText}, .${cls.areaLog} .${cls.entityLabelsMore} {
      display: none;
    }

    .${cls.entityNoText} {
      font-style: italic;
      color: var(--color-bg-row-page-brighttext);
    }

    .${cls.entityAliasText}, .${cls.statementDescriptionText} {
      font-size: 75%;
      font-style: italic;
      color: var(--color-bg-row-page-brighttext);
    }

    .${cls.entityLabelsTable} td {
      border: 1px dotted var(--color-border);
      padding: 5px 10px 5px 10px;
    }

    .${cls.logTypeError} {
      color: var(--color-txt-error);
    }

    .${cls.colRcPageFlags}, .${cls.colRcRevFlags} {
      font-size: 75%;
    }

    .${cls.timestampDate} {
      font-size: 75%;
    }

    ul, ul.${cls.flagsTags} {
      list-style-type: none;
      list-style-image: none;
      margin: 0;
      padding: 0;
    }

    .${cls.patrolMark} {
      font-size: 150%;
    }

    .${cls.patrolMarkAll} {
      font-size: 150%;
      font-weight: bold;
      color: var(--color-allpatrolled);
    }

    .${cls.rcTableHidePatrolled} {
      display: none;
    }


    /* Highlight important tags (e.g. "possible vandalism") and certain flags (e.g. "sandbox item") */

    a.${cls.flagsTags}.${cls.flagsTagsHighlightFlag} {
      font-weight: bold;
    }

    li.${cls.flagsTagsHighlightFlag} {
      font-weight: bold;
      background-color: var(--color-bg-flag-highlight);
    }

    li.${cls.flagsTagsHighlightTag} {
      font-weight: bold;
    }

    li.${cls.flagsTagsHighlightTag}.c1 {
      background-color: var(--color-bg-tag-highlight-c1);
    }

    li.${cls.flagsTagsHighlightTag}.c2 {
      background-color: var(--color-bg-tag-highlight-c2);
    }

    li.${cls.flagsTagsHighlightTag}.c3 {
      background-color: var(--color-bg-tag-highlight-c3);
    }

    li.${cls.flagsTagsHighlightTag}.c4 {
      background-color: var(--color-bg-tag-highlight-c4);
    }

    li.${cls.flagsTagsHighlightTag}.c5 {
      background-color: var(--color-bg-tag-highlight-c5);
    }

    li.${cls.oresRating}damaging-likelybad {
      font-weight: bold;
      background-color: var(--color-bg-tag-highlight-c4);
    }

    li.${cls.oresRating}damaging-verylikelybad {
      font-weight: bold;
      background-color: var(--color-bg-tag-highlight-c5);
    }


    /* Button styling */

    div.${cls.buttons} {
      position: relative;
      padding: 2px;
      margin: 2px;
      overflow: hidden;
    }

    div.${cls.buttonsSettings} {
      float: left;
    }

    div.${cls.infoSection} {
      float: left;
      margin-left: 30px;
      font-size: 80%;
    }

    div.${cls.buttonsNav} {
      float: right;
    }

    div.${cls.buttonsNav} button:last-child {
      margin-left: 100px;
    }

    button {
      padding: 10px;
    }

    .${cls.areaRc} button {
      height: 100%;
    }

    .${cls.areaAll} .${cls.mwOoButtonElementFramed}.${cls.mwOoLabelElement} > .${cls.mwOoButtonElement} > .${cls.mwOoLabelElementLabel} {
      line-height: 1.2;
      font-size: 80%;
    }

    span.${cls.buttonSmall}.oo-ui-widget a.oo-ui-buttonElement-button {
      padding: 0;
    }

    span.${cls.buttonSmall} span.oo-ui-iconElement-icon {
      min-height: 20px;
      height: 20px;
    }


    /* Limit size of diff preview */

    .${cls.mwComment} {
      display: inline-block;
      font-size: 85%;
      max-width: 600px;
      overflow: hidden;
    }

    .${cls.mwComment} + .${cls.mwDiff} {
      font-size: 85%;
    }

    iframe {
      margin: 0;
      padding: 0;
      border: none;
      min-width: 450px;
      width: 100%;
      max-height: 200px; /* TODO: iframe is rather high even for short diffs (-> because of iframe max-height == height) */
    }


    /* "Loading..." overlay */

    #${ids.overlayLoading} {
      position: absolute;
      display: none;
      font-size: 300%;
      top: 20px;
      left: 50%;
      filter: opacity(80%);
      text-shadow: 10px 10px 10px var(--color-bg-loading);
    }

    #${ids.overlayLoading}.${cls.loading} {
      /* display: block; */ /* Don't show this for now. TODO: Either tweak or remove. */
    }


    /* Hide hidden log types */

    .${cls.logTypeHidden} {
      display: none;
    }


    /* Help dialog */

    div.${cls.help} {
      display: flex;
      flex-direction: column;
      flex-wrap: nowrap;
      width: 100%;
      height: 200px;
      background: url("https://upload.wikimedia.org/wikipedia/commons/1/13/Orque-Terre_du_Milieu.jpg") #FEFEF4 no-repeat right bottom;
      background-size: contain;
    }

    .${cls.helpContact} {
      flex-grow: 1;
    }

    .${cls.helpImageCredit} {
      font: smaller italic;
    }
  `;

  /** Internal global constants */
  const orcConst = {
    /** Number of columns in the log table (counting from the message column) */
    colsLogTable: 5,

    /** Number of columns in the RC table */
    colsRcTable: 7,

    /** Max. length of description or alias list to be displayed */
    descLimit: 100,

    /** Max. length of edit summaries */
    editSummaryLength: 500,

    /** Number of days contained in the recent_changes table */
    rcDays: 30,

    /** How many recent changes should be queried to find changed pages */
    rcLimit: 50,

    /** Max number of sitelinks to be displayed on a Wikidata page header ("more" link counts as 1) */
    sizeSitelinks: 4,

    /** Max number of statements to be displayed on a Wikidata page header ("more" link counts as 1) */
    sizeStatements: 4,

    /** Ordinary time constants */
    timeHrsPerDay: 24,
    timeMilPerSec: 1000,
    timeMinPerHour: 60,
    timeSecPerMin: 60,

    /** Name of the tool (here rather than in i18n as it's used before i18n is loaded)*/
    toolName: "ORC",

    /** Subtitle of the tool */
    toolNameSub: "Open-ended Recent Changes tool"
  };

  /** Internal global objects */
  const orcObj = {
    /**
    * mw.Api object
    * @see {@link https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Api}
    */
    api: undefined,

    /** Data for the API request to get RC lists */
    apiDataRc: undefined,

    /** "Previous Page" button (OOjs UI ButtonWidget) */
    btnBack: undefined,

    /** MediaWiki CSRF token received from API call */
    tokenCsrf: undefined,

    /** MediaWiki Patrol token received from API call */
    tokenPatrol: undefined,

    /** MediaWiki Rollback token received from API call */
    tokenRollback: undefined,

    /** Window manager for OOjs UI, for first-level dialogs */
    windowManagerFirstLevel: undefined,

    /** Window manager for OOjs UI, for second-level dialogs (dialogs within dialogs) */
    windowManagerSecondLevel: undefined
  };

  /** Internal global data */
  const orcData = {
    /** The languages in which labels/descriptions should be shown if not available in user language ("en" for a start, will be extended during loadup) */
    fallBackLangs: [ "en" ],

    /** Internationaliation messages defined in MediaWiki, to be loaded */
    i18nMediaWiki: [ "ago", "blanknamespace", "markedaspatrollednotify", "seconds", "minutes", "hours", "days" ],

    /** List of pages which will be hidden during this session */
    ignoredPages: new Set(),

    /** List of users whose edits will be hidden during this session */
    ignoredUsers: new Set(),

    /** Cache for Wikibase labels/descriptions (key: title, value: Map{ key: labels/description/aliases/languages, value: Map{ key: lang, value, value }}) */
    labelCache: new Map(),

    /** Index of where in the pageIds list we are */
    pageIdCounter: -1,

    /** List of timestamps for the pageIds list. Used to determine the start point for skipping, etc. */
    pageIdTimestamps: [],

    /** List of page ids of pages that have already been shown are will be shown in this session (use pageIdCounter to determine) */
    pageIds: [],

    /** Sandbox and tour pages (hardcoded per project; use DB name) */
    // TODO: Replace most hardcoded values by the sitelinks from Q3938, Q4115189 and similar ones? Else, extend hardcoded list.
    // TODO: Include personalized sandbox pages, e.g. en:User:<*>/sandbox
    sandboxItems: {
      enwiki: [
        "Wikipedia:Sandbox", "Wikipedia:Wikidata/Wikidata Sandbox"
      ],
      metawiki: [
        "Meta:Sandbox"
      ],
      wikidatawiki: [
        "Q4115189", "Q13406268", "Q15397819", "Q16943273", "Q17339402", "Wikidata:Sandbox", "Property:P1106", "Property:P1450",
        "Property:P368", "Property:P369", "Property:P370", "Property:P578", "Property:P626", "Property:P855", "Property:P1106", "Property:P1450"
      ]
    },

    /** Time of when this session has started */
    startTime: undefined,

    /** Edit tags list */
    tags: undefined,

    /** Temporary list of pages shown in the currently opened user mode dialog */
    userModePages: [],

    /** List of the rights of the logged-in user */
    userRights: new Set()
  };

  /**
   * Internal global configuration settings
   * Note these property names are used as strings (in a pseudo-reflection way) in the code
   */
  const orcConfig = {
    /** Whether "Undo", "Thank" and similar actions should automatically patrol unpatrolled revisions */
    autoPatrol: true,

    /** Whether the patrol action triggers a confirmation dialog */
    confirmPatrol: false,

    /** Whether the patrol all action triggers a confirmation dialog */
    confirmPatrolAll: true,

    /** Whether the restore action triggers a confirmation dialog */
    confirmRestore: true,

    /** Whether the revert action triggers a confirmation dialog */
    confirmRevert: true,

    /** Whether the "revert all by user" action triggers a confirmation dialog */
    confirmRevertAll: true,

    /** Whether the thank action triggers a confirmation dialog */
    confirmThank: false,

    /** Whether the undo action triggers a confirmation dialog */
    confirmUndo: true,

    /** Whether the revision history should be reloaded after user performs a revert, undo, etc. */
    reloadAfterAction: true,

    /** Whether the browser should scroll to the top of an entry after it has been reloaded */
    scrollOnReload: true,

    /** Whether a notification should be shown for all logged actions */
    showNotification: true,

    /** Whether ORES scores should be queried */
    showOres: false,
    // TODO: Additional option to only show it if above thresholds?

    /** The maximum number of pages that can be displayed at once */
    sizePages: 5,

    /** The maximum number of revisions displayed in the history of one page */
    sizeRevisions: 50
  };

  /** Internal global mw.config shorthands */
  const orcMw = {
    /** Database name (e.g. "enwiki") */
    dbName: mw.config.get("wgDBname"),

    /** Whether we are on the Recent Changes page */
    isRcPage: mw.config.get("wgCanonicalSpecialPageName") === "Recentchanges",

    /** Whether we are on a user JavaScript page (where ORC should be enabled for testing purposes) */
    isScriptPage: mw.config.get("wgPageContentModel") === "javascript",

    /** Whether we are on Wikidata */
    isWikidata: (mw.config.get("wgDBname") === "wikidatawiki"),

    /** ORES scoring thresholds */
    oresThresh: mw.config.get("oresThresholds"),
    // TODO: Sometimes ORES damaging threshold is very low (like 42 % or even 4 %) - use a fixed value? or use the verylikelybad threshold, if set?

    /** Full URL of load.php */
    urlLoad: `${mw.config.get("wgServer")}${mw.config.get("wgLoadScript")}`,

    /** Full url of index.php */
    urlScript: `${mw.config.get("wgServer")}${mw.config.get("wgScript")}`,

    /** Base address (e.g. "//www.wikidata.org") */
    urlServer: mw.config.get("wgServer"),

    /** Language of the logged-in user */
    userLang: mw.config.get("wgUserLanguage"),

    /** User name of logged-in user */
    userName: mw.config.get("wgUserName")
  };

  /** Edit flags (unpatrolled, etc.) */
  const orcFlags = {
    minor: {
      important: false,
      symbol: "orcFlagsMinor",
      title: "orcFlagsMinorShort"
    },
    new: {
      important: false,
      symbol: "orcFlagsNewShort",
      title: "orcFlagsNew"
    },
    protected: {
      important: false,
      symbol: "orcFlagsProtectedShort",
      title: "orcFlagsProtected"
    },
    redirect: {
      important: false,
      symbol: "orcNoPreviewRedirect",
      title: "orcNoPreviewRedirect"
    },
    restored: {
      important: false,
      symbol: "orcFlagsRestoredShort",
      title: "orcFlagsRestored"
    },
    reverted: {
      important: true,
      symbol: "orcFlagsRevertedShort",
      title: "orcFlagsReverted"
    },
    sandbox: {
      important: true,
      symbol: "orcFlagsSandboxShort",
      title: "orcFlagsSandbox"
    },
    unchanged: {
      important: false,
      symbol: "orcFlagsUnchangedShort",
      title: "orcFlagsUnchanged"
    }
  };

  /** Log types (patrol, revert, ...) */
  const orcLogTypes = {
    error: {
      class: cls.logTypeError,
      count: 0,
      label: "orcLogTypeError",
      show: true
    },
    info: {
      class: cls.logTypeInfo,
      count: 0,
      label: "orcLogTypeInfo",
      show: true
    },
    patrol: {
      class: cls.logTypePatrol,
      count: 0,
      label: "orcLogTypePatrol",
      show: false
    },
    remember: {
      class: cls.logTypeRemember,
      count: 0,
      label: "orcLogTypeRemember",
      show: true
    },
    undo: {
      class: cls.logTypeUndo,
      count: 0,
      label: "orcLogTypeUndo",
      show: true
    },
    verbose: {
      class: cls.logTypeVerbose,
      count: 0,
      label: "orcLogTypeVerbose",
      show: false
    }
  };

  /**
   * Relevant Wikidata properties
   * Sorted by expected importance when used to identify the subject, not numerically
   */
  const orcProperties = [
    // Instance
    "P31",
    // Subclass
    "P279",
    // Taxon name
    "P225",
    // Sex
    "P21",
    // Series
    "P179",
    // Performer
    "P175",
    // Author
    "P50",
    // Director
    "P57",
    // Composer
    "P86",
    // Texter
    "P676",
    // Creator
    "P170",
    // Designer
    "P287",
    // Developer
    "P178",
    // Editor
    "P98",
    // Manufacturer
    "P176",
    // Producer
    "P162",
    // Publisher
    "P123",
    // Birth date
    "P569",
    // Death date
    "P570",
    // Occupation
    "P106",
    // Publication date
    "P577",
    // Administrative unit
    "P131",
    // Country
    "P17",
  ];

  /**
   * Internationaliation Labels
   * Skip a translation to fall back to English version
   */
  const i18n = {
    orcBtnApply: { en: "Apply" },
    orcBtnCancel: { en: "Cancel" },
    orcBtnClearLog: { en: "Clear Log" },
    orcBtnClearLogConfirm: { en: "Do you really want to clear the entire log table?" },
    orcBtnClearLogInfo: {
      en: "Clears the log table (for your own convenience as you already double-checked all entries, or for performance, as a long table may consume a severe amount of memory)"
    },
    orcBtnClose: { en: "Close" },
    orcBtnDisabledRights: { en: "You don't have the user rights needed for this action" },
    orcBtnHelp: { en: "Help<br />Info" },
    orcBtnHelpInfo: { en: "Tool information / help" },
    orcBtnHelpNoBreak: { en: "Help/Info" },
    orcBtnIgnoreUser: { en: "Ignore user" },
    orcBtnIgnoreUserInfo: { en: "If a user or IP is ignored, their edits won't be shown, unless the pages edited are shown for edits by other users" },
    orcBtnNext: { en: "Next<br />Page" },
    orcBtnNextInfo: { en: "Continue to next page" },
    orcBtnPageActions: { en: "Page<br />Actions" },
    orcBtnPageActionsInfo: { en: "Show more actions for this page" },
    orcBtnPageActionsNoBreak: { en: "Page Actions: $1" },
    orcBtnPatrol: { en: "Patrol<br />Edit" },
    orcBtnPatrolAll: { en: "Patrol all" },
    orcBtnPatrolAllConfirmPage: { en: "Do you really want to patrol all displayed unpatrolled revisions on this page?" },
    orcBtnPatrolAllConfirmUser: { en: "Do you really want to patrol all displayed unpatrolled revisions by this user?" },
    orcBtnPatrolAllInfo: { en: "Patrol all displayed unpatrolled revisions on this page" },
    orcBtnPatrolConfirm: { en: "Are you sure you want to patrol this edit?" },
    orcBtnPatrolConfirmTitle: { en: "Patrol edit by $1" },
    orcBtnPatrolInfo: { en: "Patrol this revision" },
    orcBtnPrev: { en: "Previous<br />Page" },
    orcBtnPrevInfo: { en: "Go back to previous page (that was actually shown - skipped pages will be skipped backwards, too)" },
    orcBtnReload: { en: "Reload" },
    orcBtnReloadInfo: { en: "Reload this page" },
    orcBtnReloadPage: { en: "Reload page" },
    orcBtnReloadPageInfo: { en: "Reload the history and other information about this page" },
    orcBtnReloadSearch: { en: "Reload<br />Search" },
    orcBtnReloadSearchInfo: { en: "Search again" },
    orcBtnRememberPage: { en: "Remember Page" },
    orcBtnRememberPageInfo: { en: "Create an entry in the log table for this page for later review" },
    orcBtnRememberRevision: { en: "Remember Revision" },
    orcBtnRememberRevisionInfo: { en: "Create an entry in the log table for this revision for later review" },
    orcBtnRestore: { en: "Restore" },
    orcBtnRestoreConfirm: { en: "Do you really want to restore this revision (and patrol all later ones)?" },
    orcBtnRestoreDisabledLatest: { en: "You cannot restore the latest revision" },
    orcBtnRestoreDisabledSingle: { en: "Restore actions have been deactivated, as history of the page is not known" },
    orcBtnRestoreInfo: { en: "Restore that revision (and patrol all later ones)" },
    orcBtnRestorePrev: { en: "Restore previous" },
    orcBtnRestorePrevConfirm: { en: "Do you really want to restore the revision before this one (and patrol all later ones)?" },
    orcBtnRestorePrevDisabledCreation: { en: "There is no revision before this one" },
    orcBtnRestorePrevDisabledTooNew: { en: "This action is only available for the oldest shown revision. Use the normal restore action instead." },
    orcBtnRestorePrevInfo: { en: "Restore the revision before this one (and patrol all later ones)" },
    orcBtnRevert: { en: "Revert<br />($1)" },
    orcBtnRevertAll: { en: "Revert all" },
    orcBtnRevertAllConfirm: { en: "Are you sure you want to try to revert all displayed edits by this user?" },
    orcBtnRevertAllConfirmTitle: { en: "Revert edits by $1" },
    orcBtnRevertAllInfo: { en: "(Try to) revert all displayed edits by this user" },
    orcBtnRevertConfirm: { en: "Are you sure you want to revert this edit series?" },
    orcBtnRevertConfirmTitle: { en: "Revert $1 {{PLURAL:$1|edit|edits}} by $2" },
    orcBtnRevertInfo: { en: "Rollback this edit series" },
    orcBtnRevertInfoDisabledLast: { en: "This edit will be reverted if you revert the last edit from the series" },
    orcBtnRevertInfoDisabledOlder: { en: "This edit cannot be reverted, as it's not part of the latest edit series, or no other user has edited this page" },
    orcBtnRevertInfoDisabledReverted: { en: "This edit cannot be reverted, as the edit series already reverts itself" },
    orcBtnRevisionActions: { en: "Revision<br />Actions" },
    orcBtnRevisionActionsInfo: { en: "Show more actions for this revision" },
    orcBtnRevisionActionsNoBreak: { en: "Revision Actions" },
    orcBtnSettingsFilter: { en: "Filter<br />settings" },
    orcBtnSettingsFilterInfo: { en: "Configure the search filters used to find the edits (triggers search)" },
    orcBtnSettingsFilterNoBreak: { en: "Filter settings" },
    orcBtnSettingsOrc: { en: "ORC<br />settings" },
    orcBtnSettingsOrcInfo: { en: "Configure the behaviour of this tool" },
    orcBtnSettingsOrcNoBreak: { en: "ORC settings" },
    orcBtnSkip: { en: "Skip<br />Time" },
    orcBtnSkipInfo: { en: "Continue by skipping some edits" },
    orcBtnSkipNoBreak: { en: "Skip Time" },
    orcBtnThank: { en: "Thank" },
    orcBtnThankConfirm: { en: "Do you really want to thank this user for this edit (and patrol it, if enabled)?" },
    orcBtnThankDisabled: { en: "You cannot thank anonymous users, bots and yourself" },
    orcBtnThankInfo: { en: "Thank the user for this edit (and patrol it, if settings allow it)" },
    orcBtnUndo: { en: "Undo" },
    orcBtnUndoConfirm: { en: "Do you really want to undo this edit (and patrol it, if settings allow it)?" },
    orcBtnUndoDisabled: { en: "Page creations cannot be undone" },
    orcBtnUndoInfo: { en: "Undo that revision (and patrol it, if settings allow it)" },
    orcBtnUserActions: { en: "User<br />Actions" },
    orcBtnUserActionsInfo: { en: "Show actions for this user" },
    orcBtnUserActionsUser: { en: "User Actions: $1" },
    orcCloseConfirm: { en: "Do you really want to close the whole ORC tool rather than just the current dialog?" },
    orcEditSummaryPlaceholder: { en: "(Leave edit comment empty to use autosummary)" },
    orcErrorInputValue: { en: "Please enter a value." },
    orcErrorRcfiltersMissing: { en: "Please enable the new recent changes filters in your project user settings. Otherwise, no search filters will be available here, and other functionality may be missing as well." },
    orcErrorTooManyDays: { en: "Not possible to skip more than $1 days." },
    orcFlagsMinor: { en: "minor" },
    orcFlagsMinorShort: { en: "Marked as minor edit by user" },
    orcFlagsNew: { en: "Created new page" },
    orcFlagsNewShort: { en: "new" },
    orcFlagsProtected: { en: "Some level of page protection is currently applied" },
    orcFlagsProtectedShort: { en: "protected" },
    orcFlagsRestored: { en: "This revision has been restored later" },
    orcFlagsRestoredShort: { en: "restored" },
    orcFlagsReverted: { en: "This revision has been reverted, i.e. a previous revision has been restored after this one" },
    orcFlagsRevertedShort: { en: "reverted" },
    orcFlagsSandbox: { en: "Edit on a sandbox or tour item" },
    orcFlagsSandboxShort: { en: "Sandbox" },
    orcFlagsUnchanged: { en: "This revision doesn't seem to have changed any content" },
    orcFlagsUnchangedShort: { en: "unchanged" },
    orcFlagsUnpatrolled: { en: "Unpatrolled edit" },
    orcHelpContact: { en: "Documentation & contact (feedback/bugs/questions/ideas)" },
    orcHelpImageCredits: { en: "Image credits: $1 by Antoine Glédel; $2" },
    orcHelpVersions: { en: "Version history / credits" },
    orcLabelPageFunctions: { en: "Functions" },
    orcLabelPageLinks: { en: "Links" },
    orcLabelPageRevisions: { en: "Revisions" },
    orcLabelPatrolled: { en: "Revision is patrolled" },
    orcLabelPatrolledAll: { en: "All revisions of this page are patrolled" },
    orcLabelQuery: { en: "Query: $1" },
    orcLabelQueryStarted: { en: "(started: $1)" },
    orcLabelRevisionActions: { en: "Actions" },
    orcLabelSettingsDisplay: { en: "Display" },
    orcLabelSettingsEdit: { en: "Editing" },
    orcLabelSettingsLog: { en: "Log" },
    orcLabelShowMore: { en: "Show more" },
    orcLabelShowPreview: { en: "Show preview" },
    orcLabelSkipAmount: { en: "Amount:" },
    orcLabelSkipButton: { en: "Skip" },
    orcLabelSkipUnit: { en: "Unit:" },
    orcLabelSkipUnitDays: { en: "days" },
    orcLabelSkipUnitHours: { en: "hours" },
    orcLabelSkipUnitMinutes: { en: "minutes" },
    orcLabelSnaktypeNoValue: { en: "(no value)" },
    orcLabelSnaktypeSomeValue: { en: "(unknown value)" },
    orcLabelUserActions: { en: "Actions" },
    orcLabelUserContribsUnpatrolled: { en: "Contributions (unpatrolled)" },
    orcLabelUserLinks: { en: "Links" },
    orcLinkContribs: { en: "Contributions" },
    orcLinkDelete: { en: "Delete page" },
    orcLinkHistory: { en: "History" },
    orcLinkPage: { en: "User page" },
    orcLinkProtect: { en: "Protect page" },
    orcLinkTalk: { en: "Talk page" },
    orcLoading: { en: "Loading" },
    orcLogIgnoreUser: { en: "Now ignoring user $1 for this session" },
    orcLogIgnoreUserRemove: { en: "Not ignoring user $1 any more" },
    orcLogNotUndone: { en: "Nothing to undo/restore here" },
    orcLogParamIrrelevant: { en: "ORC ignores irrelevant parameter: $1 ($2)" },
    orcLogParamNoRights: { en: "ORC ignores the following parameter due to your apparently missing '$1' right: $2 ($3)" },
    orcLogParamNotSupported: { en: "ORC sadly can't support parameter yet: $1 ($2)" },
    orcLogParamNotSupportedMultiTags: { en: "ORC sadly can't support multiple tags yet: $1 ($2)" },
    orcLogParamUnknown: { en: "ORC ignores unknown parameter: $1 ($2)" },
    orcLogQueryAborted: { en: "HTTP query aborted" },
    orcLogQueryTimedOut: { en: "HTTP query timed out" },
    orcLogReload: { en: "Triggered reload (name: $1, URL: $2)" },
    orcLogRememberEdit: { en: "Remembered edit for later inspection" },
    orcLogRememberPage: { en: "Remembered page for later inspection" },
    orcLogRestored: { en: "Successfully restored this revision" },
    orcLogRestoredPrev: { en: "Successfully restored the revision before this" },
    orcLogSkip: { en: "Skipped time: $1 $2 ($3 -> $4)" },
    orcLogStart: { en: "ORC started. Go hunt some!" },
    orcLogThankedUser: { en: "Successfully thanked user $1 for this edit" },
    orcLogTypeError: { en: "error" },
    orcLogTypeInfo: { en: "info" },
    orcLogTypePatrol: { en: "patrol" },
    orcLogTypeRemember: { en: "remember" },
    orcLogTypeUndo: { en: "undo" },
    orcLogTypeVerbose: { en: "verbose" },
    orcLogUndone: { en: "Successfully undone this revision" },
    orcLogUpdatePagesList: { en: "Updated pages list - added $1 pages" },
    orcNoDescription: { en: "(No description)" },
    orcNoDiff: { en: "(No difference)" },
    orcNoLabel: { en: "(No label)" },
    orcNoPreviewIntro: { en: "(No introduction found)" },
    orcNoPreviewRedirect: { en: "(Redirect)" },
    orcNoQueryName: { en: "(unnamed)" },
    orcNoResult: { en: "(Nothing found)" },
    orcNoSitelinks: { en: "(No sitelinks)" },
    orcNoStatements: { en: "(No relevant statements)" },
    orcNoValue: { en: "no value" },
    orcSettingsAutoPatrol: { en: "Automatically patrol on Undo & Co" },
    orcSettingsAutoPatrolInfo: { en: "Automatically patrol unpatrolled revisions that you undo, restore, thank their editor for, etc." },
    orcSettingsConfirmPatrol: { en: "Confirm patrols" },
    orcSettingsConfirmPatrolAll: { en: "Confirm patrol alls" },
    orcSettingsConfirmPatrolAllInfo: { en: "Show a confirmation question when the \"Patrol all\" button is clicked" },
    orcSettingsConfirmPatrolInfo: { en: "Show a confirmation question when the \"Patrol\" button is clicked" },
    orcSettingsConfirmRestore: { en: "Confirm restores" },
    orcSettingsConfirmRestoreInfo: { en: "Show a confirmation question when the \"Restore\" or  \"Restore previous\" button is clicked" },
    orcSettingsConfirmRevert: { en: "Confirm reverts" },
    orcSettingsConfirmRevertAll: { en: "Confirm multiple reverts" },
    orcSettingsConfirmRevertAllInfo: { en: "Show a confirmation question when the \"Revert all\" button is clicked in user mode" },
    orcSettingsConfirmRevertInfo: { en: "Show a confirmation question when the \"Revert\" button is clicked" },
    orcSettingsConfirmThank: { en: "Confirm thanks" },
    orcSettingsConfirmThankInfo: { en: "Show a confirmation question when the \"Thank\" button is clicked" },
    orcSettingsConfirmUndo: { en: "Confirm undos" },
    orcSettingsConfirmUndoInfo: { en: "Show a confirmation question when the \"Undo\" button is clicked" },
    orcSettingsHidePatrolled: { en: "Hide/show patrolled" },
    orcSettingsHidePatrolledInfo: { en: "Hide (or show, if already hidden) all patrolled revisions shown for this page" },
    orcSettingsReloadAction: { en: "Reload after action" },
    orcSettingsReloadActionInfo: { en: "Reload the revision history after reverting, undoing or restoring an edit, to include the revert and any intermediate edits" },
    orcSettingsScrollOnReload: { en: "Scroll on reload" },
    orcSettingsScrollOnReloadInfo: { en: "Define whether the browser should scroll to the top of an entry after it has been reloaded" },
    orcSettingsShowLogTypes: { en: "Show log types" },
    orcSettingsShowLogTypesInfo: { en: "Select which types of log entries should be shown in the log table" },
    orcSettingsShowNotification: { en: "Show notifications" },
    orcSettingsShowNotificationInfo: { en: "Show a notification popup for each logged action" },
    orcSettingsShowOres: { en: "Show ORES scores" },
    orcSettingsShowOresInfo: { en: "Display the ORES scores for the revisions (can slow down loading significantly; recommended to disable this if it takes too long to load it)" },
    orcSettingsSizePages: { en: "Number of pages" },
    orcSettingsSizePagesInfo: { en: "How many different pages should be displayed at once" },
    orcSettingsSizeRevisions: { en: "Number of revisions" },
    orcSettingsSizeRevisionsInfo: { en: "How big the excerpt of the history should be at max" },
    orcSummaryNoImprovement: { en: "Not an improvement" },
    orcSummaryNoSource: { en: "Please provide a source when changing information" },
    orcSummaryNoSubjectChange: { en: "Please don't change the subject of an existing item" },
    orcSummaryRestored: { en: "Restored revision $1" },
    orcSummaryVandalism: { en: "Vandalism" },
    orcSummaryWrongLanguage: { en: "Wrong language" },
    orcTimeBc: { en: "$1 B.C." },
    orcTimeBeforeAfter: { en: "$1 [± $2]" },
    orcTimePrecision: { en: "ca. $1 [$2]" },
  };


  /**
   * Main class/method
   * This is called as soon as user triggered the start and all dependencies are loaded
   */
  function Orc() {
    /**
     * Information about a page (e.g. an article or a project talk page)
     */
    class Page {
      /**
       * Constructor
       * @param {Object} options
       * @param {boolean} options.isProtected whether some form of page protection applies
       * @param {boolean} options.isRedirect whether this page is a redirect
       * @param {boolean} options.isSingleEditMode whether only the revisions affected by a certain filter have been loaded
       *                                           (if false, all recent changes for the page are known)
       * @param {number} options.pageId unique page ID
       * @param {string} options.title page title (incl. namespace)
       */
      constructor({ isProtected = false, isRedirect = false, isSingleEditMode = false, pageId, title }) {
        this.isProtected = isProtected;
        this.isRedirect = isRedirect;
        this.isSingleEditMode = isSingleEditMode;
        this.pageId = pageId;
        this.title = title;

        // Revision list
        this.revisions = [];

        // Get the namespace ID (e.g. 0 for main namespace)
        this.namespace = new mw.Title(this.title).getNamespaceId();

        // Get the title without namespace (e.g. "P31" rather than "Property:P31")
        this.titlePlain = new mw.Title(this.title).getMainText();
      }

      /**
       * Links a revision to the page
       * @param {Revision} revision to be added
       */
      addRevision(revision) {
        this.revisions.push(revision);
      }

      /**
      * Get the index of the revision on the page (0: newest known revision, 1: next, ...)
      * @param {Revision} revision to be checked
      * @return {number} the index of the revision
      */
      getRevisionIndex(revision) {
        return this.revisions.findIndex(element => (element.revId === revision.revId));
      }

      /**
       * Check if this is a sandbox page
       * @return {boolean} whether this is a sandbox page
       */
      isSandbox() {
        return (orcData.sandboxItems[orcMw.dbName] !== undefined && orcData.sandboxItems[orcMw.dbName].includes(this.title));
      }

      /**
       * Detect whether this is a content namespace
       * i.e. on Wikibase projects if it can have labels, sitelinks, statements, etc.
       * and on other projects whether it holds whatever is regarded as articles
       * @return {boolean} whether this is a content page
       */
      isContentNamespace() {
        if (orcMw.isWikidata) {
          const contentNamespaceNames = mw.config.get("wbEntityTypes");
          const namespaceIds = mw.config.get("wgNamespaceIds");

          for (const namespace of contentNamespaceNames.types) {
            if (namespaceIds[namespace] === this.namespace) {
              return true;
            }
          }
        } else {
          return mw.config.get("wgContentNamespaces").includes(this.namespace);
        }

        return false;
      }

      /**
       * Detect whether a certain revision belongs to the latest edit series (consecutive edits by the same user)
       * @param {Revision} revToCheck revision to check
       * @return {boolean} whether revision belongs to the latest edit series
       */
      isEditFromMostRecentSeries(revToCheck) {
        return (this.getRevisionIndex(revToCheck) < this.countMostRecentEditSeriesSize());
      }

      /**
       * Detect whether this is a virgin page, i.e. one that has not been touched by anyone else than its creator
       * (May return false negatives if page history is not known completely)
       * @return {boolean} whether this page has been edited by one single user only
       */
      isVirginPage() {
        return (this.isNewPage() && this.countMostRecentEditSeriesSize() === this.revisions.length);
      }

      /**
       * Detect whether this is a new page (i.e. the page creation is among the known revisions)
       * @return {boolean} whether this is a new page
       */
      isNewPage() {
        return this.getOldestRevision().oldRevId === 0;
      }

      /**
       * Returns the first (oldest) revision known
       * (Usually not the first revision ever, i.e. the page creation)
       * @return {Revision} oldest known revision
       */
      getOldestRevision() {
        return this.revisions[this.revisions.length - 1];
      }

      /**
       * Get the size of the latest edit series (consecutive edits by the same user)
       * @return {number} number of edits in the series
       */
      countMostRecentEditSeriesSize() {
        const latestUser = this.revisions[0].user;
        let count = 0;

        while (count < this.revisions.length && this.revisions[count].user === latestUser) {
          count++;
        }

        return count;
      }

      /**
       * Detect whether all known revisions of the page are patrolled already
       * @return {boolean} whether all known revisions of the page are patrolled already
       */
      areAllRevisionsPatrolled() {
        return this.revisions.filter(revision => revision.isPatrolled).length === this.revisions.length;
      }

      /**
       * Create the table row for the page header
       * @param {Object} $ul jQuery object to which the <li>s should be appended
       */
      createPageTableHeader($ul) {
        const page = this;

        $ul.html("");

        $ul.append($("<li>", { class: cls.colRcPageTitle }).html(this.getColumnTitle()));

        if (orcMw.isWikidata && ! page.isRedirect) {
          $ul.append($("<li>", { class: cls.colRcPageWikidataLabel }).html(this.getColumnWikidataLabel()));
        }

        $ul.append($("<li>", { class: cls.colRcPageFlags }).html(this.getColumnFlags()));
        $ul.append($("<li>", { class: cls.colRcPageDiff }).html(this.getColumnDiff()));

        if (orcMw.isWikidata && ! page.isRedirect) {
          $ul.append($("<li>", { class: cls.colRcPageWikidataSitelinks }).html(this.getColumnWikidataSitelinks()));
          $ul.append($("<li>", { class: cls.colRcPageWikidataStatements }).html(this.getColumnWikidataStatements()));
        } else if (! orcMw.isWikidata && ! page.isRedirect) {
          $ul.append($("<li>", { class: cls.colRcPageContentPreview }).html(this.getColumnContentPreview()));
          // TODO: Also show categories (also on non-content pages and also on Wikidata)
        }

        $ul.append($("<li>", { class: cls.colRcPageActions }).html(this.getColumnActions()));

        if (! page.isRedirect && page.isContentNamespace()) {
          if (orcMw.isWikidata) {
            // TODO: For Wikidata item talk or property talk pages, show wikibase content of content page?
            orcObj.api.get(new ApiDataLabel({
              loadAll: true,
              titles: [ this.titlePlain ]
            })).done(data => {
              if (! data.hasOwnProperty("error") && data.entities !== undefined && Object.keys(data.entities).length === 1) {
                const claimValues = [];
                let loadLabels = false;

                Ajax.injectLabels({
                  data: data,
                  pageId: page.pageId
                });
                Ajax.injectSitelinks({
                  data: data,
                  pageId: page.pageId
                });

                for (const values of Ajax.getClaimValues({
                  data: data,
                  onlyItems: true,
                  translateLabels: false
                }).values()) {
                  if (values !== undefined) {
                    for (const value of values) {
                      claimValues.push(value);
                    }
                  }
                }

                for (const value of claimValues) {
                  if (! orcData.labelCache.has(value)) {
                    loadLabels = true;

                    break;
                  }
                }
                // TODO: More elegant way to create an array of values from a map possibly containing arrays of values as values?

                if (loadLabels) {
                  orcObj.api.get(new ApiDataLabel({ titles: claimValues })).done(data2 => {
                    if (! data2.hasOwnProperty("error") && data2.entities !== undefined) {
                      Util.fillLabelCache(data2);
                      Ajax.injectStatements({
                        data: data,
                        pageId: this.pageId
                      });
                    } else {
                      Logger.logError({ data: data2 });
                    }
                  }).fail((jqXHR, textStatus) => Logger.logError({
                    data: textStatus,
                    msg: jqXHR
                  }));
                } else {
                  Ajax.injectStatements({
                    data: data,
                    pageId: this.pageId
                  });
                }
              } else {
                Logger.logError({ data: data });
              }
            }).fail((jqXHR, textStatus) => Logger.logError({
              data: textStatus,
              msg: jqXHR
            }));
          } else {
            orcObj.api.get(new ApiDataExtracts({
              external: false,
              title: page.title
            })).done(data => {
              $(`#${ids.idPageContentPreview}${this.pageId}`).html(Util.getIntroduction(data));
            }).fail((jqXHR, textStatus) => Logger.logError({
              data: textStatus,
              msg: jqXHR
            }));
          }
        }

        // TODO: Highlight pages that are on my watchlist -> would require additional API call (query=watchlistraw), probably not worth it
      }

      /**
       * Get the content for the title column (title and history links)
       * @return {string} HTML code
       */
      getColumnTitle() {
        return `<b><a href="${UrlFactory.getUrlPage(this)}">${this.title}</a></b><br />[<a href="${UrlFactory.getUrlHistory(this)}">${mw.msg("orcLinkHistory")}</a>]`;
      }

      /**
       * Get the content for the Wikidata labels column (label, description and their languages)
       * A placeholder is used, as the actual content is injected later via AJAX
       * @return {string} HTML code
       */
      getColumnWikidataLabel() {
        return `<div id="${ids.idPageLabel}${this.pageId}"></div>`;
        // TODO: Show spinner, so we can see whether something is still to be loaded - also for sitelinks/statements
        // TODO: Decrease font size of description (and language)?
        // TODO: Don't use an ID, as this is re-used in log table
        // FIXME: In log table, this is not shown, as no AJAX call is made any more. Should be taken from page itself then (or from any other label cache).
      }

      /**
       * Get the content for the flags column (sandbox flag, page is new, ...)
       * @return {string} HTML code
       */
      getColumnFlags() {
        const flags = [];

        if (this.isSandbox()) {
          flags.push(UiFactory.getFlag(orcFlags.sandbox));
        }

        if (this.isNewPage()) {
          flags.push(UiFactory.getFlag(orcFlags.new));
        }

        if (this.isRedirect) {
          flags.push(UiFactory.getFlag(orcFlags.redirect));
        }

        if (this.isProtected) {
          // TODO: The API actually returns the exact protection levels, not just yes/no -> display them? Even add information how often the page has been protected in the past?
          flags.push(UiFactory.getFlag(orcFlags.protected));
        }

        return `<ul class="${cls.flagsTags}">${flags.join("")}</ul>`;
      }

      /**
       * Get the content for the diff column (diff over all shown revisions or some selected ones)
       * @return {string} HTML code
       */
      getColumnDiff() {
        // TODO: Show, but what exactly? First to last edit shown? How could the user select revisions to compare? -> Possibly add a toggle button to the timestamp, last to selected revisions are compared
        // -> For the diff buttons, the oldest revision should also have a "prev rev" button (unless it's the page creation), and there should be no buttons and no diff at all if only one revision is shown (no matter if it's the page creation)
        // -> Or no buttons, just a "diff to current" link?
        // TODO: Global diff only as popup rather than in page header?
      }

      /**
       * Get the content for the page actions column (action button)
       * @return {string} HTML code
       */
      getColumnActions() {
        const page = this;

        const btnMore = new OO.ui.ButtonWidget({
          icon: "menu",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnPageActions")),
          title: mw.msg("orcBtnPageActionsInfo")
        });

        btnMore.on("click", () => UiFactory.openDialog({
          context: page,
          page: "diaPage"
        }));

        return btnMore.$element;
      }

      /**
       * Get the content for the Wikidata sitelinks column (sitelinks and previews)
       * @return {string} HTML code
       */
      getColumnWikidataSitelinks() {
        return `<div id="${ids.idPageSitelink}${this.pageId}"></div>`;
      }

      /**
       * Get the content for the Wikidata statements column
       * @return {string} HTML code
       */
      getColumnWikidataStatements() {
        return `<div id="${ids.idPageStatement}${this.pageId}"></div>`;
      }

      /**
       * Get the content for the content preview column (the introduction of the page, not in Wikidata)
       * @return {string} HTML code
       */
      getColumnContentPreview() {
        return `<div id="${ids.idPageContentPreview}${this.pageId}"></div>`;
      }
    }

    /**
     * Information about a revision of a page (i.e. one particular edit or version)
     */
    class Revision {
      /**
       * Constructor
       * @param {Object} options
       * @param {string} options.comment summary of the edit, unparsed
       * @param {string[]} options.isMinor whether this revision is marked as minor
       * @param {boolean} options.isPatrolled whether this revision has been patrolled already
       * @param {number} options.oldRevId ID of the previous revision (0 for page creations)
       * @param {Object} options.oresScores JSON object with ORES scores as delivered from the API
       * @param {Page} options.page page this revision belongs to
       * @param {string} options.parsedComment summary of the edit, parsed by MediaWiki
       * @param {number} options.revId ID of the revision (in the history, not the recent_changes)
       * @param {string} options.sha1 SHA-1 hash of the revision content
       * @param {string[]} options.tags (edit filter) tags appended to the revision
       * @param {string} options.timestamp date/time (ISO) at which the revision was created
       * @param {string} options.user name of the user who created this revision
       */
      constructor({ comment, isMinor = false, isPatrolled = false, oldRevId, oresScores, page, parsedComment, revId, sha1, tags, timestamp, user }) {
        this.comment = (comment === undefined) ? "" : comment;
        this.isMinor = isMinor;
        this.isPatrolled = isPatrolled;
        this.oldRevId = oldRevId;
        this.oresScores = oresScores;
        this.page = page;
        this.parsedComment = parsedComment;
        this.revId = revId;
        this.sha1 = sha1;
        this.tags = tags;
        this.timestamp = timestamp;
        this.user = user;
      }

      /**
       * Check if this is an edit from anonymous user (IP)
       * @return {boolean} whether this is an anonymous user (IP)
       */
      isAnonEdit() {
        return mw.util.isIPAddress(this.user, false);
      }

      /**
       * Check if this is an edit by yourself
       * @return {boolean} whether this is your own edit
       */
      isOwnEdit() {
        return this.user === orcMw.userName;
      }

      /**
       * Detect whether this revision created the page
       * @return {boolean} whether this revision created the page
       */
      isPageCreation() {
        return this.oldRevId === 0;
      }

      /**
      * Detect whether this revision is the newest (known) on the page
      * @return {boolean} whether the revision is the newest on the page
      */
      isNewestRevision() {
        return (this.page.revisions.length > 0 && this.page.getRevisionIndex(this) === 0);
      }

      /**
      * Detect whether this revision is the oldest (known) on the page
      * @return {boolean} whether the revision is the oldest on the page
      */
      isOldestRevision() {
        return (this.page.revisions.length > 0 && this.page.getRevisionIndex(this) === this.page.revisions.length - 1);
      }

      /**
      * Detect whether this revision is the same as the one before (no content change, e.g. a log action)
      * @return {boolean} whether the revision content is the same as the revision before
      */
      isUnchanged() {
        return (! this.isOldestRevision() && this.page.revisions[this.page.getRevisionIndex(this) + 1].sha1 === this.sha1);
      }

      /**
      * Detect whether this revision has been restored since (i.e. a newer revision has the same content) and returns the index of that revision
      * @return {number} page revision of the newest revision restoring this one (-1 for none)
      */
      wasRestoredByRevisionIndex() {
        return (this.isNewestRevision()) ? -1 : this.page.revisions.slice(0, this.page.getRevisionIndex(this) - 1).findIndex(element => (element.sha1 === this.sha1));
      }

      /**
      * Detect whether this revision has been reverted since (i.e. a newer revision has the same content as an older revision)
      * @return {boolean} whether the revision has been reverted
      */
      hasBeenReverted() {
        if (! this.isNewestRevision() && ! this.isOldestRevision()) {
          const index = this.page.getRevisionIndex(this);

          for (let i = index + 1; i < this.page.revisions.length - 1; i++) {
            const restoreIndex = this.page.revisions[i].wasRestoredByRevisionIndex();

            if (restoreIndex > -1 && restoreIndex < index) {
              return true;
            }
          }
        }

        return false;
      }

      /**
       * Create the table row for the revision
       * @param {Object} $tbody jQuery object to which the <tr> should be appended
       */
      createRevisionTableRow($tbody) {
        const thisRevision = this;
        const $tr = $("<tr>", { id: `${ids.idRevTable}${this.revId}` });

        // TODO: Highlight certain edits (as flags? coloring the line?)
        // - own edits (as flags? coloring the line)?
        // - edits by ignored users (if page is displayed anyway because of other edits)
        // - revisions not met by the filters
        // - the edit that triggered the page to appear in the list?

        $tr.append($("<td>", { class: cls.colRcRevTimestamp }).html(this.getColumnTimestamp()));
        $tr.append($("<td>", { class: cls.colRcRevUser }).html(this.getColumnUser()));
        $tr.append($("<td>", { class: cls.colRcRevDiff }).html(this.getColumnDiff()));
        $tr.append($("<td>", { class: cls.colRcRevFlags }).html(this.getColumnFlags()));

        if (orcData.userRights.has("patrol")) {
          $tr.append($("<td>", { class: cls.colRcRevActionPatrol }).html(this.getColumnActionPatrol()));
        }

        if (orcData.userRights.has("rollback")) {
          $tr.append($("<td>", { class: cls.colRcRevActionRevert }).html(this.getColumnActionRevert()));
        }

        $tr.append($("<td>", { class: cls.colRcRevActionMore }).html(this.getColumnActionMore()));

        $tbody.append($tr);

        // Load diff
        // TODO: Make automatic diff preview optional (but provide a way to load it if disabled)
        if (! this.isPageCreation() && ! this.isUnchanged()) {
          orcObj.api.get(new ApiDataDiff({
            fromRev: this.oldRevId,
            toRev: this.revId
          })).done(data => Ajax.injectDiff(data))
            .fail((jqXHR, textStatus) => Logger.logError({
              data: textStatus,
              msg: jqXHR
            }));
        }

        if (this.page.isSingleEditMode) {
          // In Single Edit Mode, patrolled flags and ORES scores could already be loaded from the initial RC query, no need to perform a second one
          Ajax.injectPatrolButton(thisRevision);
          Ajax.injectOresScores(thisRevision);
        } else if (this.isOwnEdit() || this.isUnchanged()) {
          // Consider all edits by myself or not modifying any content as patrolled (saves requests to check), and don't care for ORES score
          Ajax.addPatrolCheckmarks({
            $div: $(`div#${ids.idRevPatrolState}${this.revId}`),
            revision: this
          });
        } else {
          // Load patrol button, if revision is unpatrolled
          // As patrol state is not in history, it has to be gathered separately
          // And as the API doesn't support any decent way, do this by querying all recent changes around the time and find the one we need
          orcObj.api.get(
            new ApiDataRc({
              namespaces: [ this.page.namespace ],
              timestamp: this.timestamp,
              user: this.user
            })
          ).done(data => {
            if (! data.hasOwnProperty("error") && data.query !== undefined && data.query.recentchanges !== undefined) {
              // Check results for this particular revision
              const queryRevision = data.query.recentchanges.find(checkedRev => (checkedRev.revid === thisRevision.revId));

              if (queryRevision !== undefined) {
                thisRevision.isPatrolled = (queryRevision.patrolled !== undefined);
                thisRevision.oresScores = queryRevision.oresscores;

                Ajax.injectPatrolButton(thisRevision);
                Ajax.injectOresScores(thisRevision);
              }
            }
          }).fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));
        }
      }

      /**
       * Get the content for the timestamp column (timestamp, linked to diff)
       * @return {string} HTML code
       */
      getColumnTimestamp() {
        return $("<a>", { href: UrlFactory.getUrlDiff(this) }).html(UiFactory.showTimestamp(this.timestamp));
      }

      /**
       * Renderer for user links
       * For registered users, the user page will be linked
       * For anonymous editors, the IP address will be highlighted in a unique color (to help distinguishing similar IPs)
       * @return {string} HTML code to be displayed in the table
       */
      getColumnUser() {
        // TODO: Highlight names of users that have been reverted or patrolled before in this session?
        // TODO: Highlight users that are on my watchlist?
        // TODO: Show an indication whether user was only active on this item
        // TODO: Coloring of links (especially user page, user talk page) depending on existence of pages -> check mw.Title.exists(), mw.Title.getMain(), mw.Title.getUrl(params), mw.util.getUrl(title, params) and mw.(Api?).parse(wikitext, ok, err) (https://de.wikipedia.org/wiki/Wikipedia:Technik/Skin/JS/API / https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Title)

        const userLink = `<a href="${UrlFactory.getUrlUser(this.user)}">${this.user}</a>`;
        const userColored = `<span style="background:#${Util.intToRGB(Util.hashCode(this.user))};"><span style="color:#${Util.intToRGB(Util.hashCode(this.user))}; filter: invert(100%);">${this.user}</span></span>`;

        const $span = $("<span>").append(this.isAnonEdit() ? userColored : userLink).append($("<br>"));

        if (! this.page.isSingleEditMode) {
          const userName = this.user;

          // Show user actions button, except in user mode
          const btnActions = new OO.ui.ButtonWidget({
            icon: "menu",
            label: new OO.ui.HtmlSnippet(mw.msg("orcBtnUserActions")),
            title: mw.msg("orcBtnUserActionsInfo")
          });

          btnActions.on("click", () => UiFactory.openDialog({
            context: userName,
            page: "diaUser"
          }));

          $span.append(btnActions.$element);
        }

        return $span;
      }

      /**
       * Get the content for the diff column (summary and diff preview)
       * A spinner is used as a placeholder, as the actual content is injected later via AJAX
       * @return {string} HTML code
       */
      getColumnDiff() {
        const comment = `<span class="${cls.mwComment}" title="${this.comment}">${(this.parsedComment !== undefined) ? this.parsedComment : this.comment}</span>`;

        if (this.isPageCreation() || this.isUnchanged()) {
          return comment;
        }

        const $spinDiv = $("<div></div>");

        $spinDiv.append($("<tr></tr>").html($("<td></td>").html($.createSpinner({
          size: "large",
          type: "block"
        }))));

        const diff = `<div class="${cls.mwDiff}" id="${ids.idRevDiffPreview}${this.revId}">${$spinDiv.html()}</div>`;

        return `${comment}${diff}`;

        // TODO: Diff spinner div should have a certain size, so the layout won't change much once the real diff is loaded
        // TODO: Trigger diff reload if user clicks on loading symbol
        // TODO: Move edit summary as title in the iframe (technically, it must be some element in the iframe, e.g. the table or the body)? Would save space, but bad for touch.
        // -> (Optionally) Move it into Revision Actions (as "Revision Infos" instead)?
      }

      /**
       * Get the content for the diff column (summary only, no diff preview)
       * @return {string} HTML code
       */
      getColumnDiffNoPreview() {
        const comment = `<span class="${cls.mwComment}" title="${this.comment}">${this.parsedComment}</span>`;
        const difflink = `${orcMw.urlScript}?diff=${this.revId}&curid=${this.page.pageId}&oldid=${this.oldRevId}&title=${mw.util.wikiUrlencode(this.page.title)}`;

        return `${comment}<br />[<a href="${difflink}">diff</a>]`;
      }

      /**
       * Get the content for the flags/tags column (flags: e.g. page creation; tags: e.g. possible vandalism)
       * @return {string} HTML code
       */
      getColumnFlags() {
        const $ul = $("<ul>", { class: cls.flagsTags });

        if (this.isPageCreation()) {
          $ul.append(UiFactory.getFlag(orcFlags.new));
          // TODO: Different flag if this is a user page, created by another user
        }

        if (this.isMinor) {
          $ul.append(UiFactory.getFlag(orcFlags.minor));
        }

        if (this.isUnchanged()) {
          $ul.append(UiFactory.getFlag(orcFlags.unchanged));
        }

        if (this.wasRestoredByRevisionIndex() > -1) {
          $ul.append(UiFactory.getFlag(orcFlags.restored));
        }

        if (this.hasBeenReverted()) {
          $ul.append(UiFactory.getFlag(orcFlags.reverted));
        }

        // TODO: Flag bot changes
        // TODO: Flag externally triggered Wikibase changes (sitelink moves, etc.) -> How to detect externally triggered edits (page moves and deletions)? They are not type=external. -> recheck this (according to docs, it is primarily used by Wikidata)

        if (this.tags !== undefined && orcData.tags !== undefined && orcData.tags !== null) {
          for (const tag of this.tags) {
            let tagDef = orcData.tags.find(Util.findTag, tag);

            if (tagDef === undefined) {
              tagDef = { name: tag };
            }

            const $li = $("<li>", { class: tagDef.cssClass });

            if (tagDef.highlight !== undefined) {
              $li.addClass(cls.flagsTagsHighlightTag);
              $li.addClass(tagDef.highlight);
            }

            // TODO: Additionally highlight unhighlighted tags that have been in the search parameters?

            if (tagDef.description !== undefined) {
              $li.prop("title", tagDef.description);
            }

            $li.append((tagDef.label === undefined) ? tagDef.name : tagDef.label);

            $ul.append($li);
          }
        }

        return $ul;
      }

      /**
       * Get the content for the patrol action column (button)
       * @return {string} HTML code
       */
      getColumnActionPatrol() {
        // TODO: This and probably other IDs are no more unique if a user mode popup is opened (this is why the patrol checkmark actually works in the background, too, but the coloring does not for the other checkmarks)
        return $("<div></div>", { id: `${ids.idRevPatrolState}${this.revId}` }).html($.createSpinner({
          size: "small",
          type: "block"
        }));

        // TODO: Trigger button reload if user clicks on loading symbol?
      }

      /**
       * Get the content for the revert action column (revert button, if edit is from most recent series and this doesn't lead back to page creation)
       * @return {string} HTML code
       */
      getColumnActionRevert() {
        if (this.page.isEditFromMostRecentSeries(this) && ! this.page.isVirginPage()) {
          return UiFactory.getButtonRevert(this).$element;
        }

        // TODO: If no revert button is to be shown, place the Undo button here instead? -> Especially if user doesn't have the rollback right
      }

      /**
       * Get the content for the "More Revision actions" column (button)
       * @return {string} HTML code
       */
      getColumnActionMore() {
        const rev = this;

        const btnMore = new OO.ui.ButtonWidget({
          icon: "menu",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnRevisionActions")),
          title: mw.msg("orcBtnRevisionActionsInfo")
        });

        btnMore.on("click", () => UiFactory.openDialog({
          context: rev,
          page: "diaRevision"
        }));

        return btnMore.$element;
      }
    }

    /**
     * Logger class to create and manage log entries
     */
    class Logger {
      /**
       * Constructor
       * @param {Object} options
       * @param {LogType} options.logType type of the log entry (revert, patrol, error, ...)
       * @param {string} options.msg message to log
       * @param {Page} options.page page for which this log entry is
       * @param {Revision} options.revision main revision for which this log entry is (e.g. the reverted edit)
       */
      constructor({ logType, msg, page, revision }) {
        this.logType = logType;
        this.msg = msg;
        this.page = (page === undefined && revision !== undefined) ? revision.page : page;
        this.revision = revision;
        this.timestamp = (new Date()).toISOString();

        // Count
        this.logType.count++;
      }

      /**
       * Create a log entry and insert it to the log table
       * Don't use this directly, use the delegate functions, e.g. logMsg()
       * @param {Object} options
       * @param {Object} options.data JSON object with the response of an erroneous API request (parsed version will replace msg)
       * @param {LogType} options.logType type of the log entry (revert, patrol, error, ...)
       * @param {string} options.msg message to log
       * @param {boolean} options.notify whether a notification should be triggered (if user settings allow this)
       * @param {Page} options.page page for which this log entry is
       * @param {Revision} options.revision main revision for which this log entry is (e.g. the reverted edit)
       */
      static log({ data, logType, msg, notify = true, page, revision }) {
        const $table = $(`tbody.${cls.logTableBody}`);

        // Don't show errors for aborted queries and timeouts as well as empty errors -> convert to verbose log
        if (logType === orcLogTypes.error && data !== undefined && data.exception === "abort") {
          Logger.logVerbose({ msg: mw.msg("orcLogQueryAborted") });

          return;
        } else if (logType === orcLogTypes.error && data !== undefined && data.exception === "timeout") {
          Logger.logVerbose({ msg: mw.msg("orcLogQueryTimedOut") });

          return;
        } else if (logType === orcLogTypes.error && data !== undefined && data.exception === "" && data.textStatus === "error") {
          Logger.logVerbose({
            data: data,
            msg: msg,
            page: page,
            revision: revision
          });

          return;
        }

        const logEntry = new Logger({
          logType: logType,
          msg: (data !== undefined) ? Logger.getAjaxErrorMessage(data) : msg,
          page: page,
          revision: revision
        });

        logEntry.createLogTableRow($table);

        // More details for error messages in verbose log
        if (data !== undefined && logType !== orcLogTypes.verbose) {
          if (msg !== undefined) {
            Logger.logVerbose({ msg: msg });
          }

          if (Logger.getAjaxErrorMessage(data) !== JSON.stringify(data)) {
            Logger.logVerbose({ data: JSON.stringify(data) });
          }
        }

        Pagination.scrollLogAreaToTop();

        if (notify && orcConfig.showNotification && logType !== orcLogTypes.verbose) {
          mw.notify(logEntry.msg, {
            autoHideSeconds: (logEntry.isError()) ? "long" : "short",
            tag: logType.class,
            type: (logEntry.isError()) ? "error" : "info"
          });
        }

        // Also log errors to the browser's console, e.g. for the trace information
        if (logEntry.isError()) {
          console.group();
          console.error(logEntry.msg);
          console.trace();
          console.groupEnd();
        }
      }

      /**
       * Clears the log table by removing all rows
       */
      static clearLog() {
        $(`tbody.${cls.logTableBody}`).html("");
      }

      /**
       * Log an info message, usually without further context
       * @param {Object} options see log(), only logType will be preset
       */
      static logMsg(options) {
        options.logType = orcLogTypes.info;
        Logger.log(options);
      }

      /**
       * Log a debug message, only shown in non-default verbose mode
       * @param {Object} options see log(), only logType will be preset
       */
      static logVerbose(options) {
        options.logType = orcLogTypes.verbose;
        Logger.log(options);
      }

      /**
       * Log an error (to ORC's log plus the browser's console)
       * @param {Object} options see log(), only logType will be preset
       */
      static logError(options) {
        options.logType = orcLogTypes.error;
        Logger.log(options);
      }

      /**
       * Log a patrol entry
       * @param {Object} options see log(), only logType and msg will be preset
       */
      static logPatrol(options) {
        options.logType = orcLogTypes.patrol;
        options.msg = mw.msg("markedaspatrollednotify", options.revision.page.title);
        Logger.log(options);
      }

      /**
       * Remember a revision (i.e. create dummy log entry for it)
       * @param {Object} options see log(), only logType and msg will be preset
       */
      static logRememberRevision(options) {
        options.logType = orcLogTypes.remember;
        options.msg = mw.msg("orcLogRememberEdit");
        Logger.log(options);
      }

      /**
       * Remember a page (i.e. create dummy log entry for it)
       * @param {Object} options see log(), only logType and msg will be preset
       */
      static logRememberPage(options) {
        options.logType = orcLogTypes.remember;
        options.msg = mw.msg("orcLogRememberPage");
        Logger.log(options);
      }

      /**
       * Log an undo/revert/restore entry
       * @param {Object} options see log(), only logType and msg will be preset
       */
      static logUndo(options) {
        options.logType = orcLogTypes.undo;
        Logger.log(options);

        // TODO: Restore/restore previous: Doesn't make much sense to show diff in log -> either show own diff, or none at all
      }

      /**
       * Return whether this log entry is an error log
       * @return {boolean} whether this log entry is an error log
       */
      isError() {
        return (this.logType === orcLogTypes.error);
      }

      /**
       * Create the table row for the log entry
       * @param {Object} $table jQuery object to which the <tr> should be appended
       */
      createLogTableRow($table) {
        const $tdMsg = $("<td>", { class: cls.colLogMsg });
        const $tr = $("<tr>", { class: this.logType.class });

        if (! this.logType.show) {
          $tr.addClass(cls.logTypeHidden);
        }

        $tr.append($("<td>", { class: cls.colLogTimestamp }).html(this.getColumnTimestamp()));
        $tr.append($("<td>", { class: cls.colLogType }).html(this.getColumnLogType()));
        $tr.append($tdMsg.html(this.getColumnMsg()));

        if (this.page === undefined && this.revision === undefined) {
          // If no further context is given, span the message over the whole rest of the table
          $tdMsg.prop("colspan", orcConst.colsLogTable);
        } else {
          $tr.append($("<td>", { class: cls.colLogTitle }).html((this.page !== undefined) ? this.page.getColumnTitle() : ""));
          $tr.append($("<td>", { class: cls.colLogWikidataLabel }).html((this.page !== undefined) ? this.page.getColumnWikidataLabel() : ""));

          $tr.append($("<td>", { class: cls.colLogRevisionDiff }).html((this.revision !== undefined) ? this.revision.getColumnDiffNoPreview() : ""));
          $tr.append($("<td>", { class: cls.colLogUser }).html((this.revision !== undefined) ? this.revision.getColumnUser() : ""));
        }

        $table.prepend($tr);
      }

      /**
       * Get the content for the timestamp column (ISO timestamp)
       * @return {string} HTML code
       */
      getColumnTimestamp() {
        return UiFactory.showTimestamp(this.timestamp);
      }

      /**
       * Get the content for the log type column (log type, count per type)
       * @return {string} HTML code
       */
      getColumnLogType() {
        return `${mw.msg(this.logType.label)}<br />#${this.logType.count}`;
      }

      /**
       * Get the content for the log message column (mesage text)
       * @return {string} HTML code
       */
      getColumnMsg() {
        return this.msg;
      }

      /**
       * Extract the error message from an AJAX response
       * @param {Object} data JSON object
       * @return {string} error message
       */
      static getAjaxErrorMessage(data) {
        if (data.error !== undefined && data.error.messages !== undefined && data.error.messages.html !== undefined && data.error.messages.html["*"] !== undefined) {
          return data.error.messages.html["*"];
        } else if (data.errors !== undefined && Array.isArray(data.errors)) {
          return data.errors.map(val => {
            if (val["*"] !== undefined) {
              return val["*"];
            } else if (val.error !== undefined && val.info !== undefined) {
              return `${val.error}: ${val.info}`;
            } else {
              return val.error;
            }
          }).join(" / ");
        } else if (data.error !== undefined && data.error.code !== undefined && data.error.info !== undefined) {
          return `${data.error.code}: ${data.error.info}`;
        } else {
          return JSON.stringify(data);
        }
      }
    }

    /**
     * Parent class for all API data classes (where the data for specific API calls is stored)
     */
    class ApiData {
      /**
       * Constructor
       * @param {boolean} isForeign whether this is a call to a foreign project's API (no assertuser needed then)
       */
      constructor(isForeign) {
        this.format = "json";
        this.errorformat = "plaintext";

        if (! isForeign) {
          this.assert = "user";
        }
      }
    }

    /**
     * Data about a user, especially the user's rights
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=query%2Busers}
     */
    class ApiDataUser extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {number} options.user user name
       */
      constructor({ user }) {
        super(false);

        // Query history
        this.action = "query";
        this.list = "users";
        this.usprop = "rights";
        this.ususers = user;
      }
    }

    /**
     * Data for the wbgetentities API call to load labels and descriptions
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=wbgetentities}
     */
    class ApiDataLabel extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {boolean} options.loadAll whether all data should be loaded (false: only labels/descriptions/aliases)
       * @param {string} options.titles titles (Qid or Pid) of the pages to be loaded
       */
      constructor({ loadAll = false, titles }) {
        super(false);

        // Query Wikidata labels / descriptions
        this.action = "wbgetentities";
        this.ids = titles;
        this.redirects = "no";

        // Use own fallback mechanism, as otherwise there won't be decent fallbacks for English
        this.languagefallback = false;

        if (loadAll) {
          this.props = "labels|descriptions|aliases|sitelinks|claims";
        } else {
          this.props = "labels|descriptions|aliases";
        }
      }
    }

    /**
     * Data for the compare API call to load diffs
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=compare}
     */
    class ApiDataDiff extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {number} options.fromRev revision from which the diff should be shown
       * @param {number} options.toRev revision to which the diff should be shown
       */
      constructor({ fromRev, toRev }) {
        super(false);

        // Query history
        this.action = "compare";
        this.fromrev = fromRev;
        this.torev = toRev;
      }
    }

    /**
     * Data for the query/revisions API call to load the history of a page
     * (and some additional page infos, like redirect status)
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=query%2Brevisions}
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=query%2Binfo}
     */
    class ApiDataHistory extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {number} options.pageId unique page ID
       */
      constructor({ pageId }) {
        super(false);

        // Limit history to the length of the recent_changes table, as only the edits contained there can be patrolled
        const date = new Date();
        date.setDate(date.getDate() - orcConst.rcDays);

        // Query history
        this.action = "query";
        this.inprop = "protection";
        this.prop = "info|revisions";
        this.pageids = pageId;
        this.rvprop = "ids|flags|timestamp|user|comment|parsedcomment|sha1|tags";
        this.rvlimit = orcConfig.sizeRevisions; // TODO: If there are more recent_changes entries than sizeRevisions, show some indication for this! Or in general: If the last shown revision is not the page creation, show additional "history link" line
        this.rvend = date.toISOString();
      }
    }

    /**
     * Data for the query/recentchanges API call to load the recent changes
     * Used for different purposes:
     * - determine the next pages to be displayed according to the search filters
     * - detect the patrolling status of a specific revision
     * - find all edits by a specific user
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=query%2Brecentchanges}
     */
    class ApiDataRc extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {number[]} options.namespaces only edits in the namespaces with these IDs should be found
       * @param {string} options.rcshow search filter
       * @param {string} options.timestamp ISO timestamp that should be used for both from and to, so only edits at this exact time should be found (for single mode)
       * @param {string} options.user only edits made by the user with this name should be found
       */
      constructor({ namespaces, rcshow, timestamp, user }) {
        super(false);

        // Query recent changes
        this.action = "query";
        this.list = "recentchanges";

        if (timestamp !== undefined) {
          // Looking for a specific edit to get its patrolled state - narrow down the search as much as possible
          this.rcprop = [ "ids" ];

          if (orcData.userRights.has("patrol")) {
            this.rcprop.push("patrolled");
          }

          this.rclimit = "max";

          if (namespaces !== undefined && namespaces.length === 1 && namespaces[0] === 0 && orcConfig.showOres && orcMw.oresThresh !== null) {
            // For performance reasons and as ORES won't be enabled in most other namespaces anyway, only query ORES scores for main NS
            // TODO: Don't query ORES at all if not enabled on project or for user
            // TODO: Though not always, sometimes loading ORES scores is very slow - do it independend from patrolled status query, so that at least this is fast? Or is the option enough, so it can be turned off if slow?
            // FIXME: Always assure that orcMw.oresThresh is loaded (currently, the config is sometimes missing)
            this.rcprop.push("oresscores");
          }
        } else if (user !== undefined) {
          // Looking for the edits of a particular user
          this.rcprop = [ "comment", "flags", "ids", "parsedcomment", "sha1", "tags", "timestamp", "title", "user" ];

          if (orcData.userRights.has("patrol")) {
            this.rcprop.push("patrolled");
          }

          if (orcConfig.showOres && orcMw.oresThresh !== null) {
            this.rcprop.push("oresscores");
          }

          this.rclimit = orcConst.rcLimit; // TODO: If we don't need much additional requests per user, we can set this to "max"
        } else {
          // Looking for all recent changes according to the filter settings
          this.rcprop = "ids|title|user|timestamp";
          // TODO: Cache patrolled status (and ORES scores) of revisions found here, so they don't have to be queried later?
          this.rclimit = orcConst.rcLimit;
          // TODO: use "max" rclimit to increase chance to find matching new pages? Then load only a certain amount of pages to list, to avoid having outdated results (Be sure to continue from that point then, and not from the end of the list)?
          // TODO: Discard all future findings on every page change?

          // Renew the tokens (not actually needed every time, but they could expire otherwise)
          this.meta = "tokens";
          this.type = "csrf|patrol|rollback";
        }

        // Filters
        this.rcend = timestamp;
        this.rcexcludeuser = undefined;
        this.rcnamespace = namespaces;
        this.rcshow = (rcshow === undefined) ? [] : rcshow;
        this.rcstart = timestamp;
        this.rctag = undefined;
        this.rctype = [ "edit", "new" ];
        this.rcuser = user;

        // Continuation
        this.rccontinue = undefined;
      }
    }

    /**
     * Data for the patrol API call used to patrol a revision
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=patrol}
     */
    class ApiActionPatrol extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {number} options.revId ID of the revision to be patrolled
       */
      constructor({ revId }) {
        super(false);

        this.action = "patrol";
        this.revid = revId;
        this.token = orcObj.tokenPatrol;
      }
    }

    /**
     * Data for the rollback API call used to revert a series of revisions
     * (will always rollback to the last revision that has been created by another user than the current revision)
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=rollback}
     */
    class ApiActionRevert extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {string} options.summary custom edit summary (if empty, default summary will be used)
       * @param {string} options.title title of the page in which edits should be reverted
       * @param {string} options.user name of the user whose edits should be reverted
       */
      constructor({ summary, title, user }) {
        super(false);

        this.action = "rollback";
        this.summary = summary;
        this.title = title;
        this.user = user;
        this.token = orcObj.tokenRollback;
      }
    }

    /**
     * Data for the edit API call used to undo or restore
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=edit}
     */
    class ApiActionEdit extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {string} options.summary optional edit summary (otherwise autosummary will be used)
       * @param {string} options.title title of the page in which edits should be performed
       * @param {number} options.undo revision ID of the latest revision to be undone
       * @param {number} options.undoAfter revision ID of the revision for which the changes after it should be undone
       */
      constructor({ summary, title, undo, undoAfter }) {
        super(false);

        // TODO: Use basetimestamp/starttimestamp to detect edit conflicts?
        // TODO: Implement captcha handling? (https://www.mediawiki.org/wiki/API:Edit#CAPTCHAs_and_extension_errors)

        this.action = "edit";
        this.nocreate = true;
        this.summary = summary;
        this.title = title;
        this.token = orcObj.tokenCsrf;
        this.undo = undo;
        this.undoafter = undoAfter;
      }
    }

    /**
     * Data for the thank API call used to thank a user for an edit
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=thank}
     */
    class ApiActionThank extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {string} options.rev revision for which the thank should be sent
       */
      constructor({ rev }) {
        super(false);

        this.action = "thank";
        this.rev = rev;
        this.source = "orc";
        this.token = orcObj.tokenCsrf;
      }
    }

    /**
     * Data for the query API call to load Wikidata sitelink extracts for article preview
     * To be queried from the respective project's API
     * @see {@link https://www.wikidata.org/w/api.php?action=help&modules=query}
     */
    class ApiDataExtracts extends ApiData {
      /**
       * Constructor
       * @param {Object} options
       * @param {boolean} options.external whether the request goes out to an external project
       * @param {string} options.title title of the page to be loaded
       */
      constructor({ external, title }) {
        super(external);

        // Query page
        this.action = "query";
        this.exchars = 1000;
        this.exintro = true;
        this.titles = title;
        this.prop = "extracts|info";
      }
    }

    /**
     * Filter settings dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function FiltersDialog(config) {
      FiltersDialog.parent.call(this, config);
    }

    /**
     * User actions dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function UserActionsDialog(config) {
      UserActionsDialog.parent.call(this, config);
    }

    /**
     * Page actions dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function PageActionsDialog(config) {
      PageActionsDialog.parent.call(this, config);
    }

    /**
     * (More) revision actions dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function RevisionActionsDialog(config) {
      RevisionActionsDialog.parent.call(this, config);
    }

    /**
     * Skip Time dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function SkipDialog(config) {
      SkipDialog.parent.call(this, config);
      this.inputAmount = undefined;
      this.inputUnit = undefined;
    }

    /**
     * Help/Info dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function HelpDialog(config) {
      HelpDialog.parent.call(this, config);
    }

    /**
     * (ORC) settings dialog class constructor (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog}
     */
    function SettingsDialog(config) {
      SettingsDialog.parent.call(this, config);
    }

    /**
     * User actions dialog subclass for the "Links" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class UserActionsDialogPageLinks extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelUserLinks"));
      }

      /**
       * Init the UI for a specific user
       * @param {string} user name of the user
       */
      setUser(user) {
        const contribLink = `<a href="${UrlFactory.getUrlUserContribs(user)}">${mw.msg("orcLinkContribs")}</a>`;
        const talkLink = `<a href="${UrlFactory.getUrlUserTalk(user)}">${mw.msg("orcLinkTalk")}</a>`;
        const userLink = `<a href="${UrlFactory.getUrlUser(user)}">${mw.msg("orcLinkPage")}</a>`;

        this.$element.html(`<ul><li>${userLink}</li><li>${talkLink}</li><li>${contribLink}</li></ul>`);
      }
    }

    /**
     * User actions dialog subclass for the "unpatrolledcontribs" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class UserActionsDialogPageContribs extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelUserContribsUnpatrolled"));
      }

      /**
       * Init the UI for a specific user
       * @param {string} user name of the user
       */
      setUser(user) {
        this.setData(user);

        // TODO: Cache patrolled status for revisions patrolled in user mode (as they are likely to re-appear in the result list)? Otherwise, discard future pages list at least
        // TODO: "Hide patrolled" button (should hide complete pages); makes more sense in the "all contributions" mode though
        // TODO: Allow to open ORC for a user directly, from Special:Contributions

        // Placeholder content
        this.$element.html($("<div>", { class: cls.areaUserRc }));
      }
    }

    /**
     * User actions dialog subclass for the "Actions" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class UserActionsDialogPageActions extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelUserActions"));
      }

      /**
       * Init the UI for a specific user
       * @param {string} user name of the user
       */
      setUser(user) {
        const fieldset = new OO.ui.FieldsetLayout();
        const switchIgnore = new OO.ui.ToggleSwitchWidget({ value: orcData.ignoredUsers.has(user) });

        switchIgnore.on("change", doIgnore => {
          if (doIgnore) {
            // TODO: Warn when trying to ignore a user you previously reverted
            // TODO: If user is ignored, hide currently displayed edits?
            orcData.ignoredUsers.add(user);
            Logger.logMsg({ msg: mw.msg("orcLogIgnoreUser", user) });
            Pagination.clearFuturePageIds();
          } else {
            orcData.ignoredUsers.delete(user);
            Logger.logMsg({ msg: mw.msg("orcLogIgnoreUserRemove", user) });
          }
        });

        fieldset.addItems([
          new OO.ui.FieldLayout(switchIgnore, {
            align: "inline",
            help: mw.msg("orcBtnIgnoreUserInfo"),
            label: mw.msg("orcBtnIgnoreUser")
          })
        ]);

        this.$element.html(fieldset.$element);

        // TODO: Block user
        // TODO: Warn user
      }
    }

    /**
     * Revision actions dialog subclass for the "Actions" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class RevisionActionsDialogPageActions extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelRevisionActions"));
      }

      /**
       * Init the UI for a specific revision
       * @param {Revision} revision revision
       */
      setRevision(revision) {
        const fieldset = new OO.ui.FieldsetLayout();

        // Remember revision
        const btnRemember = new OO.ui.ButtonWidget({
          icon: "pushPin",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnRememberRevision")),
          title: mw.msg("orcBtnRememberRevisionInfo")
        });

        // Thank (+ patrol)
        const btnThank = new OO.ui.ButtonWidget({
          icon: "heart",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnThank")),
          title: mw.msg("orcBtnThankInfo")
        });

        // Undo (+ patrol)
        const btnUndo = UiFactory.getButtonEdit({
          confirm: mw.msg("orcBtnUndoConfirm"),
          doConfirm: orcConfig.confirmUndo,
          icon: "undo",
          label: mw.msg("orcBtnUndo"),
          log: mw.msg("orcLogUndone"),
          revision: revision,
          title: mw.msg("orcBtnUndoInfo"),
          undo: revision.revId
        });

        // Restore (+ patrol all later revs)
        const btnRestore = UiFactory.getButtonEdit({
          confirm: mw.msg("orcBtnRestoreConfirm"),
          doConfirm: orcConfig.confirmRestore,
          icon: "redo",
          label: mw.msg("orcBtnRestore"),
          log: mw.msg("orcLogRestored"),
          revision: revision,
          title: mw.msg("orcBtnRestoreInfo"),
          undo: revision.page.revisions[0].revId,
          undoAfter: revision.revId
        });

        // Restore previous revision (+ patrol all later revs)
        const btnRestorePrevious = UiFactory.getButtonEdit({
          confirm: mw.msg("orcBtnRestorePrevConfirm"),
          doConfirm: orcConfig.confirmRestore,
          icon: "redo",
          label: mw.msg("orcBtnRestorePrev"),
          log: mw.msg("orcLogRestoredPrev"),
          revision: revision,
          title: mw.msg("orcBtnRestorePrevInfo"),
          undo: revision.page.revisions[0].revId,
          undoAfter: revision.oldRevId
        });

        // Revert
        const btnRevert = UiFactory.getButtonRevert(revision);
        // TODO: have one-line versions of Patrol and Revert buttons - or make all buttons two-lined

        // Patrol
        const btnPatrol = UiFactory.getButtonPatrol(revision);

        const buttons = [ btnRemember, btnThank, btnUndo, btnRestore, btnRestorePrevious, btnRevert, btnPatrol ];

        // Button actions
        btnRemember.on("click", () => Logger.logRememberRevision({ revision: revision }));

        btnThank.on("click", () => {
          if (orcConfig.confirmThank) {
            OO.ui.confirm(mw.msg("orcBtnThankConfirm")).done(confirmed => {
              if (confirmed) {
                Actions.thank(revision, btnThank);
              }
            });
          } else {
            Actions.thank(revision, btnThank);
          }
        });

        for (const button of buttons) {
          // All actions should close the dialog
          button.on("click", () => UiFactory.getCurrentWindowManager().getCurrentWindow().close());
        }

        // Button disabling
        if (revision.isAnonEdit() || revision.isOwnEdit()) {
          // TODO: Also disable for bots
          btnThank.setDisabled(true);
          btnThank.setTitle(mw.msg("orcBtnThankDisabled"));
        }

        if (revision.isPageCreation()) {
          btnUndo.setDisabled(true);
          btnUndo.setTitle(mw.msg("orcBtnUndoDisabled"));
        }

        if (revision.page.isSingleEditMode) {
          btnRestore.setDisabled(true);
          btnRestore.setTitle(mw.msg("orcBtnRestoreDisabledSingle"));
        } else if (revision.isNewestRevision()) {
          btnRestore.setDisabled(true);
          btnRestore.setTitle(mw.msg("orcBtnRestoreDisabledLatest"));
        }

        if (revision.page.isSingleEditMode) {
          btnRestorePrevious.setDisabled(true);
          btnRestorePrevious.setTitle(mw.msg("orcBtnRestoreDisabledSingle"));
        } else if (revision.isPageCreation()) {
          btnRestorePrevious.setDisabled(true);
          btnRestorePrevious.setTitle(mw.msg("orcBtnRestorePrevDisabledCreation"));
        } else if (revision.page.getOldestRevision().revId !== revision.revId) {
          btnRestorePrevious.setDisabled(true);
          btnRestorePrevious.setTitle(mw.msg("orcBtnRestorePrevDisabledTooNew"));
        }

        // Layout
        fieldset.addItems(UiFactory.getDialogButtonLayout(buttons));
        this.$element.html(fieldset.$element);

        // TODO: Can "undoafter" be used to implement a "Revert" for non-latest edit series (i.e. undo several subsequent edits of a user at once, if another user made edits since)?
      }
    }

    /**
     * Page actions dialog subclass for the "Revisions" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class PageActionsDialogPageRevisions extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelPageRevisions"));
      }

      /**
       * Init the UI for a specific page
       * @param {Page} page page
       */
      setPage(page) {
        const fieldset = new OO.ui.FieldsetLayout();

        // Patrol all (visible) on Page button
        const btnPatrol = new OO.ui.ButtonWidget({
          icon: "check",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnPatrolAll")),
          title: mw.msg("orcBtnPatrolAllInfo")
        });

        // Reload Page History button
        const btnReload = new OO.ui.ButtonWidget({
          icon: "search",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnReloadPage")),
          title: mw.msg("orcBtnReloadPageInfo")
        });

        // Hide/show patrolled revisions button
        const btnHide = new OO.ui.ButtonWidget({
          icon: "funnel",
          label: new OO.ui.HtmlSnippet(mw.msg("orcSettingsHidePatrolled")),
          title: mw.msg("orcSettingsHidePatrolledInfo")
        });

        const buttons = [ btnPatrol, btnReload, btnHide ];

        // Button actions
        btnPatrol.on("click", () => {
          if (orcConfig.confirmPatrolAll) {
            OO.ui.confirm(mw.msg("orcBtnPatrolAllConfirmPage")).done(confirmed => {
              if (confirmed) {
                Actions.patrolAllPage(page);
              }
            });
          } else {
            Actions.patrolAllPage(page);
          }
        });

        btnReload.on("click", () => Pagination.reloadHistory({ page: page }));

        btnHide.on("click", () => {
          for (const revision of page.revisions) {
            if (revision.isPatrolled) {
              $(`#${ids.idRevTable}${revision.revId}`).toggleClass(cls.rcTableHidePatrolled);
            }
          }
        });

        if (! orcData.userRights.has("patrol")) {
          btnPatrol.setDisabled(true);
          btnPatrol.setTitle(mw.msg("orcBtnDisabledRights"));
        }

        for (const button of buttons) {
          // All actions should close the dialog
          button.on("click", () => UiFactory.getCurrentWindowManager().getCurrentWindow().close());
        }

        // Layout
        fieldset.addItems(UiFactory.getDialogButtonLayout(buttons));
        this.$element.html(fieldset.$element);
      }
    }

    /**
     * Page actions dialog subclass for the "Functions" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class PageActionsDialogPageFunctions extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelPageFunctions"));
      }

      /**
       * Init the UI for a specific page
       * @param {Page} page page
       */
      setPage(page) {
        const fieldset = new OO.ui.FieldsetLayout();

        // Remember page
        const btnRemember = new OO.ui.ButtonWidget({
          icon: "pushPin",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnRememberPage")),
          title: mw.msg("orcBtnRememberPageInfo")
        });

        btnRemember.on("click", () => Logger.logRememberPage({ page: page }));

        fieldset.addItems(UiFactory.getDialogButtonLayout([ btnRemember ]));
        this.$element.html(fieldset.$element);

        // TODO: Ignore Page on subsequent runs
      }
    }

    /**
     * Page actions dialog subclass for the "Links" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class PageActionsDialogPageLinks extends OO.ui.PageLayout {
      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelPageLinks"));
      }

      /**
       * Init the UI for a specific page
       * @param {Page} page page
       */
      setPage(page) {
        const $ul = $("<ul>");

        if (orcData.userRights.has("delete")) {
          $ul.append(`<li><a href="${UrlFactory.getUrlDelete(page.title)}">${mw.msg("orcLinkDelete")}</a></li>`);
        }

        if (orcData.userRights.has("protect")) {
          $ul.append(`<li><a href="${UrlFactory.getUrlProtect(page.title)}">${mw.msg("orcLinkProtect")}</a></li>`);
        }

        $ul.append(`<li><a href="${UrlFactory.getUrlHistory(page)}">${mw.msg("orcLinkHistory")}</a></li>`);

        // TODO: Pageinfos (&action=info)
        // TODO: WhatLinksHere (Special:WhatLinksHere/title)
        // TODO: Talk page (if not talk page already)

        this.$element.html($ul);
      }
    }

    /**
     * Settings dialog subclass for the "Log" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class SettingsDialogPageLog extends OO.ui.PageLayout {
      /**
       * Constructor
       * @param {string} name unique symbolic name of page
       */
      constructor(name) {
        super(name);

        // Show Log Type selection
        const fieldset = new OO.ui.FieldsetLayout({
          help: mw.msg("orcSettingsShowLogTypesInfo"),
          label: mw.msg("orcSettingsShowLogTypes")
        });

        Object.values(orcLogTypes).forEach(logType => {
          const toggleType = new OO.ui.ToggleSwitchWidget({ value: logType.show });
          const item = new OO.ui.FieldLayout(toggleType, {
            align: "right",
            label: mw.msg(logType.label)
          });

          toggleType.on("change", doShow => {
            logType.show = doShow;
            $(`.${logType.class}`).toggleClass(cls.logTypeHidden);
          });

          fieldset.addItems([ item ]);
        });

        // Clear log button
        const btnClear = new OO.ui.ButtonWidget({
          flags: "destructive",
          icon: "trash",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnClearLog")),
          title: mw.msg("orcBtnClearLogInfo")
        });

        btnClear.on("click", () => {
          OO.ui.confirm(mw.msg("orcBtnClearLogConfirm")).done(confirmed => {
            if (confirmed) {
              Logger.clearLog();
            }
          });
        });

        fieldset.addItems(UiFactory.getDialogButtonLayout([ btnClear ]));
        this.$element.html(fieldset.$element);
      }

      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelSettingsLog"));
      }
    }

    /**
     * Settings dialog subclass for the "Edit" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class SettingsDialogPageEdit extends OO.ui.PageLayout {
      /**
       * Constructor
       * @param {string} name unique symbolic name of page
       */
      constructor(name) {
        super(name);

        const fieldset = new OO.ui.FieldsetLayout();

        fieldset.addItems([
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmRevert"),
            setting: "confirmRevert",
            title: mw.msg("orcSettingsConfirmRevertInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmRevertAll"),
            setting: "confirmRevertAll",
            title: mw.msg("orcSettingsConfirmRevertAllInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmUndo"),
            setting: "confirmUndo",
            title: mw.msg("orcSettingsConfirmUndoInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmRestore"),
            setting: "confirmRestore",
            title: mw.msg("orcSettingsConfirmRestoreInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmPatrol"),
            setting: "confirmPatrol",
            title: mw.msg("orcSettingsConfirmPatrolInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmPatrolAll"),
            setting: "confirmPatrolAll",
            title: mw.msg("orcSettingsConfirmPatrolAllInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsConfirmThank"),
            setting: "confirmThank",
            title: mw.msg("orcSettingsConfirmThankInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsReloadAction"),
            setting: "reloadAfterAction",
            title: mw.msg("orcSettingsReloadActionInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsAutoPatrol"),
            setting: "autoPatrol",
            title: mw.msg("orcSettingsAutoPatrolInfo")
          })
        ]);

        // TODO: Option to disable "Next" buttons for a second (or so) after one of them has been pressed

        this.$element.html(fieldset.$element);
      }

      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelSettingsEdit"));
      }
    }

    /**
     * Settings dialog subclass for the "Display" page (OOjs UI)
     * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout}
     */
    class SettingsDialogPageDisplay extends OO.ui.PageLayout {
      /**
       * Constructor
       * @param {string} name unique symbolic name of page
       */
      constructor(name) {
        super(name);

        const fieldset = new OO.ui.FieldsetLayout();

        fieldset.addItems([
          UiFactory.getSettingsNumberField({
            label: mw.msg("orcSettingsSizePages"),
            max: 50,
            min: 1,
            setting: "sizePages",
            title: mw.msg("orcSettingsSizePagesInfo")
          }),
          UiFactory.getSettingsNumberField({
            label: mw.msg("orcSettingsSizeRevisions"),
            max: 500,
            min: 1,
            setting: "sizeRevisions",
            title: mw.msg("orcSettingsSizeRevisionsInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsScrollOnReload"),
            setting: "scrollOnReload",
            title: mw.msg("orcSettingsScrollOnReloadInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsShowNotification"),
            setting: "showNotification",
            title: mw.msg("orcSettingsShowNotificationInfo")
          }),
          UiFactory.getSettingsToggleSwitch({
            label: mw.msg("orcSettingsShowOres"),
            setting: "showOres",
            title: mw.msg("orcSettingsShowOresInfo")
          })
        ]);

        this.$element.html(fieldset.$element);

        // TODO: Make configurable whether multiple revisions per page should be loaded or just the one found
        // TODO: Also make configurable how old (in days) the listed revisions may be (absolute or in relation to the found edit)?
        // TODO: Auto-load next page once all edits shown have been patrolled
        // TODO: Skip pages from the beginning if all edits are patrolled [this probably should be ignored when going back (maybe even when going forth again, until the original last page is reached)]
        // TODO: grey out patrolled edits? Don't display patrolled revisions at all (+ skip pages for which no revisions are shown)?  [add. options: only if no unpatrolled edits follow or preceed]
        // TODO: own page? -> refresh tokens action (if possible at all: currently, renewal works automatically if done before expiring, but not afterwards)
        // TODO: Clear/edit list of ignored users/pages?
        // TODO: Add latin transcription to non-lation sitelinks/labels
        // TODO: Make IP coloring (and time coloring) optional
      }

      /**
       * Set or unset the outline item
       * @override
       */
      setupOutlineItem() {
        this.outlineItem.setLabel(mw.msg("orcLabelSettingsDisplay"));
      }
    }

    /**
     * Utility class to group all methods about changing pages
     */
    class Pagination {
      /**
       * Skip the changes of a certain time amount (e.g. 2 hours or 5 days)
       * @param {Object} options
       * @param {number} options.amount amount of time
       * @param {string} options.unit unit of time: m|h|d
       */
      static skip({ amount, unit }) {
        Pagination.clearFuturePageIds();

        const date = new Date(orcData.pageIdTimestamps[orcData.pageIdCounter]);

        if (unit === "m") {
          date.setMinutes(date.getMinutes() - amount);
        } else if (unit === "h") {
          date.setHours(date.getHours() - amount);
        } else if (unit === "d") {
          date.setDate(date.getDate() - amount);
        }

        orcObj.apiDataRc.continue = undefined;
        orcObj.apiDataRc.rcstart = date.toISOString();

        Logger.logVerbose({ msg: mw.msg("orcLogSkip", amount, unit, orcData.pageIdTimestamps[orcData.pageIdCounter], date.toISOString()) });
        Pagination.showNextPageOrLoadMore();

        // FIXME: Scroll up after skip (though scrollTop is in place already in showNextPageOrLoadMore())
      }

      /**
       * Clear the pre-loaded list of next pages to be displayed, but do not forget the already displayed pages
       * (e.g. when skipping time or ignoring users, not on complete reload)
       */
      static clearFuturePageIds() {
        orcData.pageIds.splice(orcData.pageIdCounter + 1);
        orcData.pageIdTimestamps.splice(orcData.pageIdCounter + 1);
      }

      /**
       * Scroll to top inside the log area (i.e. display newest log entries)
       */
      static scrollLogAreaToTop() {
        $(`div.${cls.areaLog}`).animate({ scrollTop: 0 }, "fast");
      }

      /**
       * Scroll browser view to the top of the page (e.g. on page change)
       */
      static scrollMainAreaToTop() {
        $(`#${ids.mainDiv}`).animate({ scrollTop: 0 }, "fast");
      }

      /**
       * Scroll browser view to the top of the entry (e.g. on reload)
       * @param {Page} page page which should be displayed
       */
      static scrollPageToTop(page) {
        $(`#${ids.idPageTable}${page.pageId}`)[0].scrollIntoView();
      }

      /**
       * Reloads the RC table, with the current recent changes, filtered according the current filter settings
       * @param {string} url URL as delivered by the RC Filters component, e.g. https://www.wikidata.org/wiki/Special:RecentChanges?limit=50&days=7&urlversion=2&namespace=2&tagfilter=possible+vandalism
       */
      static reloadSearch(url) {
        const queryName = $(`.${cls.mwSavedQueryTitle}`).html();
        $(`.${cls.infoQuery}`).html(mw.msg("orcLabelQuery", (queryName === undefined || queryName === "") ? mw.msg("orcNoQueryName") : queryName));
        Logger.logVerbose({ msg: mw.msg("orcLogReload", queryName, url) });

        Pagination.updatePagesList({});

        if (url === undefined) {
          // No new filters, just reset time and continuation and repeat queryName
          orcObj.apiDataRc.rccontinue = undefined;
          orcObj.apiDataRc.rcstart = "now";
        } else {
          // Parse given URL to API commands (iterate through all parameters to detect unhandled ones)
          const tagColorMatcher = /^tagfilter__(.*)_color$/;

          orcObj.apiDataRc = new ApiDataRc({});
          let invertNamespaces = false;
          let oresReview = false;
          let noOresReview = false;

          // Reset tag highlighting
          orcData.tags = mw.config.get("wgRCFiltersChangeTags");

          // Load parameters
          for (const [ param, value ] of new URL(url).searchParams.entries()) {
            if (value === undefined) {
              continue;
            }

            const values = value.split(";");

            if (param === "damaging" || param === "goodfaith") {
              // API doesn't distinguish between those two (or their three levels each) and only offers !oresreview and oresreview
              // So just consider any of the two in any of the "likelybad" levels as "oresreview"
              if (values.includes("likelybad") || values.includes("verylikelybad")) {
                oresReview = true;
              }

              if (values.includes("likelygood")) {
                noOresReview = true;
              }
            } else if (param === "hidebots" && value === "1") {
              orcObj.apiDataRc.rcshow.push("!bot");
            } else if (param === "hidebyothers" && value === "1") {
              orcObj.apiDataRc.rcuser = orcMw.userName;
            } else if (param === "hidehumans" && value === "1") {
              orcObj.apiDataRc.rcshow.push("bot");
            } else if (param === "hidepageedits" && value === "1") {
              Util.removeFromArray(orcObj.apiDataRc.rctype, "edit");
            } else if (param === "hidemajor" && value === "1") {
              orcObj.apiDataRc.rcshow.push("minor");
            } else if (param === "hideminor" && value === "1") {
              orcObj.apiDataRc.rcshow.push("!minor");
            } else if (param === "hidemyself" && value === "1") {
              orcObj.apiDataRc.rcexcludeuser = orcMw.userName;
            } else if (param === "hidenewpages" && value === "1") {
              Util.removeFromArray(orcObj.apiDataRc.rctype, "new");
            } else if ((param === "hidepatrolled" && value === "1") || (param === "reviewStatus" && values.includes("unpatrolled"))) {
              if (orcData.userRights.has("patrol")) {
                orcObj.apiDataRc.rcshow.push("!patrolled");
              } else {
                Logger.logError({ msg: mw.msg("orcLogParamNoRights", "patrol", param, (value === undefined || value === "") ? mw.msg("orcNoValue") : value) });
              }
              // TODO: If !patrolled is in the filter criteria and there's only one rev found for the page, we can assume it to be not patrolled (though we still have to load ORES scores, so no real benefit, unless ORES is disabled in settings or project)
            } else if ((param === "hideunpatrolled" && value === "1") || (param === "reviewStatus" && (values.includes("manual") || values.includes("auto")))) {
              if (orcData.userRights.has("patrol")) {
                orcObj.apiDataRc.rcshow.push("patrolled");
              } else {
                Logger.logError({ msg: mw.msg("orcLogParamNoRights", "patrol", param, (value === undefined || value === "") ? mw.msg("orcNoValue") : value) });
              }
            } else if (param === "invert" && value === "1") {
              invertNamespaces = true;
            } else if (param === "namespace") {
              orcObj.apiDataRc.rcnamespace = value.split(";");
            } else if (param === "tagfilter") {
              if (value.split("|").length > 1) {
                Logger.logError({ msg: mw.msg("orcLogParamNotSupportedMultiTags", param, value) });
                // TODO: Implement multi-tag support by triggering the API multiple times
              } else {
                orcObj.apiDataRc.rctag = value;
              }
            } else if (param === "userExpLevel") {
              const values = value.split(";");

              if (values.includes("unregistered") && ! values.includes("registered")) {
                orcObj.apiDataRc.rcshow.push("anon");
              } else if (! values.includes("unregistered") && values.includes("registered")) {
                orcObj.apiDataRc.rcshow.push("!anon");
              }

              for (const val of values) {
                if (val !== "all" && val !== "unregistered" && val !== "registered") {
                  // Parameter values newcomer, learner, ... unsupported by API
                  Logger.logError({ msg: mw.msg("orcLogParamNotSupported", param, val) });
                }
              }
            } else if (tagColorMatcher.test(param) && orcData.tags !== undefined && orcData.tags !== null) {
              const tag = tagColorMatcher.exec(param)[1];
              const tagDef = orcData.tags.find(Util.findTag, tag);

              if (tagDef !== undefined) {
                tagDef.highlight = value;
              }
            } else if ([
              "days", "enhanced", "hidecategorization", "hidelastrevision", "hidelog", "hidepreviousrevisions", "hideWikibase", "highlight", "lifilter",
              "lifiltercase", "lifilterexpr", "limit", "urlversion", "withJS", "target", "title"
            ].includes(param)) {
              // Silently ignore these parameter, as they are not relevant for ORC
              Logger.logVerbose({ msg: mw.msg("orcLogParamIrrelevant", param, (value === undefined || value === "") ? mw.msg("orcNoValue") : value) });
            } else if ([ "flaggedrevs", "translations", "watchlist" ].includes(param)) {
              // Parameters unsupported by API
              Logger.logError({ msg: mw.msg("orcLogParamNotSupported", param, (value === undefined || value === "") ? mw.msg("orcNoValue") : value) });
            } else {
              // Unknown parameter
              Logger.logError({ msg: mw.msg("orcLogParamUnknown", param, (value === undefined || value === "") ? mw.msg("orcNoValue") : value) });
            }
          }

          // Try to translate the damaging/goodfaith levels to oresrevies / !oresreview
          if (oresReview && ! noOresReview) {
            orcObj.apiDataRc.rcshow.push("oresreview");
          } else if (noOresReview && ! oresReview) {
            orcObj.apiDataRc.rcshow.push("!oresreview");
          }

          // If "Exclude namespaces" was selected, search for the namespaces that are NOT in the rcnamespace list
          if (invertNamespaces && orcObj.apiDataRc.rcnamespace !== undefined) {
            orcObj.apiDataRc.rcnamespace = Object.keys(mw.config.get("wgFormattedNamespaces")).filter(val => ! orcObj.apiDataRc.rcnamespace.includes(val));
          }
        }

        Pagination.showNextPageOrLoadMore();
      }

      /**
       * Displays the next page. If not enough recent changes are pre-loaded, more of them are loaded first.
       */
      static showNextPageOrLoadMore() {
        orcObj.api.abort();

        Pagination.scrollMainAreaToTop();
        Pagination.showLoadingOverlay({});

        if (orcData.pageIdCounter >= orcData.pageIds.length - orcConfig.sizePages) {
          if (orcObj.apiDataRc.rcexcludeuser === undefined && orcData.ignoredUsers.size > 0) {
            // If no one is excluded yet (e.g. "hidemyself" option), exclude first ignored user
            // This is just to reduce number of unneeded results, ignored users will be filtered out afterwards in any case
            orcObj.apiDataRc.rcexcludeuser = orcData.ignoredUsers.values().next().value;
          }

          orcObj.api.get(orcObj.apiDataRc, { async: false })
            .done(data => Pagination.loadNextPagesToProcess(data))
            .fail((jqXHR, textStatus) => Logger.logError({
              data: textStatus,
              msg: jqXHR
            }));
        }

        Pagination.showNextPage();
      }

      /**
       * Displays the next page (page of the tool, may contain several pages = articles, etc.)
       */
      static showNextPage() {
        const $div = $(`div.${cls.areaRc}`);
        $div.html("");
        let hasResults = false;

        orcObj.btnBack.setDisabled(orcData.pageIdCounter < 0);

        for (let i = 0; i < orcConfig.sizePages && orcData.pageIdCounter < orcData.pageIds.length - 1; i++) {
          orcData.pageIdCounter++;
          $div.append(UiFactory.createRcTable(orcData.pageIds[orcData.pageIdCounter]));
          Pagination.showPage({ pageId: orcData.pageIds[orcData.pageIdCounter] });
          hasResults = true;
        }

        $(`.${cls.infoTime}`).html(new Date(orcData.pageIdTimestamps[orcData.pageIdCounter]).toLocaleString());

        if (! hasResults) {
          Pagination.showLoadingOverlay({ showLoading: false });
          $(`div.${cls.areaRc}`).html(mw.msg("orcNoResult"));
        }
      }

      /**
       * Displays a particular page
       * @param {Object} options
       * @param {boolean} options.hasBeenEdited whether the page is reloaded due to an edit
       * @param {number} options.pageId the pageId of the page to be displayed
       */
      static showPage({ hasBeenEdited = false, pageId }) {
        Pagination.showLoadingOverlay({ showEdited: hasBeenEdited });
        // TODO: Upon reload of a single page, blur only this one?

        orcObj.api.get(new ApiDataHistory({ pageId: pageId }))
          .done(data => Pagination.loadPageHistory(data))
          .fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));
      }

      /**
       * Re-build the history UI for the current page, incl. changes that were made after the history was loaded
       * @param {Object} options
       * @param {boolean} options.hasBeenEdited whether the page is reloaded due to an edit
       * @param {Page} options.page the page to be reloaded
       */
      static reloadHistory({ hasBeenEdited = false, page }) {
        Pagination.showPage({
          hasBeenEdited: hasBeenEdited,
          pageId: page.pageId
        });
        // TODO: Here, a cache would actually be useful: Diffs won't change, and known patrolled edits won't become unpatrolled (but Wikidata stuff may change between revisions!)
        // -> save diff in the revision, then in the reload method, pass the revisions, and don't re-query the diff and, if the revision is known to be patrolled, the patrol state
        // -> make sure that cache then is applied to goBack() as well
        // Or simply only load revisions that are new and leave known ones untouched?

        if (orcConfig.scrollOnReload && $(`#${ids.mainDiv}`).scrollTop() > $(`#${ids.idPageTable}${page.pageId}`).offset().top) {
          Pagination.scrollPageToTop(page);
        }
      }

      /**
       * Go back to the page that was shown previously (skipped pages will be skipped as well)
       */
      static goBack() {
        orcData.pageIdCounter = orcData.pageIdCounter - (2 * orcConfig.sizePages);

        if (orcData.pageIdCounter < 0) {
          orcData.pageIdCounter = -1;
        }

        Pagination.showNextPage();
      }

      /**
       * Process the results of an API query for recent changes
       * @param {Object} data JSON object with API response data
       */
      static loadNextPagesToProcess(data) {
        if (! data.hasOwnProperty("error") && data.query !== undefined && data.query.recentchanges !== undefined) {
          // Set continue markers
          orcObj.apiDataRc.rccontinue = (data.continue !== undefined) ? data.continue.rccontinue : undefined;

          // Set tokens
          if (data.query.tokens !== undefined) {
            orcObj.tokenCsrf = data.query.tokens.csrftoken;
            orcObj.tokenPatrol = data.query.tokens.patroltoken;
            orcObj.tokenRollback = data.query.tokens.rollbacktoken;
          }

          Pagination.updatePagesList({
            batchcomplete: (data.batchcomplete !== undefined),
            recentchanges: data.query.recentchanges
          });
        } else {
          Logger.logError({ data: data });
        }
      }

      /**
       * Process the results of an API query for a page history
       * @param {Object} data JSON object with API response data
       */
      static loadPageHistory(data) {
        if (! data.hasOwnProperty("error") && data.query !== undefined && data.query.pages !== undefined && Object.keys(data.query.pages).length === 1) {
          const pageId = Object.keys(data.query.pages)[0];
          const page = new Page({
            isProtected: (data.query.pages[pageId].protection !== undefined && data.query.pages[pageId].protection.length > 0),
            isRedirect: (data.query.pages[pageId].redirect !== undefined),
            pageId: pageId,
            title: data.query.pages[pageId].title
          });

          // FIXME: Possible error: "data.query.pages[pageId].revisions is not iterable" (seems to have been problem on server side, as the query did not deliver any revisions, while it did when repeated; never the less, handle this)
          for (const queryRevision of data.query.pages[pageId].revisions) {
            page.addRevision(new Revision({
              comment: queryRevision.comment,
              isMinor: (queryRevision.minor !== undefined),
              oldRevId: queryRevision.parentid,
              page: page,
              parsedComment: queryRevision.parsedcomment,
              revId: queryRevision.revid,
              sha1: queryRevision.sha1,
              tags: queryRevision.tags,
              timestamp: queryRevision.timestamp,
              user: queryRevision.user
            }));
          }

          Pagination.loadRcTable(page);
        } else {
          Logger.logError({ data: data });
        }

        // FIXME: fails if page to be loaded has been deleted since (user has to press next twice then)
      }

      /**
       * Update the list of pages to be loaded next, by adding more pages or clearing the list
       * @param {Object} options
       * @param {boolean} options.batchcomplete whether there are no further results after these
       * @param {Object} options.recentchanges JSON list of recent changes as received from API request for recent changes list (or undefined, to clear the list)
       */
      static updatePagesList({ batchcomplete = false, recentchanges }) {
        let addedNewPagesToList = 0;

        if (recentchanges === undefined) {
          orcData.pageIds = [];
          orcData.pageIdTimestamps = [];
          orcData.pageIdCounter = -1;

          return;
        }

        // Add the not yet displayed pages to the list (nothing else is needed from recentchanges)
        for (const queryRevision of recentchanges) {
          if (! orcData.pageIds.includes(queryRevision.pageid) && ! orcData.ignoredUsers.has(queryRevision.user)) {
            orcData.pageIds.push(queryRevision.pageid);
            orcData.pageIdTimestamps.push(queryRevision.timestamp);
            addedNewPagesToList++;

            // TODO: Combine pageIds/pageIdTimestamps/pageIdCounter/pages/revisions/queryname to one object
          }

          // TODO: skip Flow and translation pages here? Or remove the patrol/revert buttons there?
        }

        Logger.logVerbose({ msg: mw.msg("orcLogUpdatePagesList", addedNewPagesToList) });

        if (addedNewPagesToList === 0 && ! batchcomplete) {
          orcObj.api.get(orcObj.apiDataRc, { async: false })
            .done(data => Pagination.loadNextPagesToProcess(data))
            .fail((jqXHR, textStatus) => Logger.logError({
              data: textStatus,
              msg: jqXHR
            }));
        }
      }

      /**
       * Fill the UI to display a page and its revisions
       * @param {Page} page page to be loaded
       */
      static loadRcTable(page) {
        Pagination.showLoadingOverlay({ showLoading: false });

        const $rcDiv = (page.isSingleEditMode) ? $(`div.${cls.areaUserRc}`) : $(`div.${cls.areaRc}`);
        const $existingTable = $rcDiv.find(`#${ids.idPageTable}${page.pageId}`);

        page.createPageTableHeader($existingTable.find(`ul.${cls.rcTableHead}`));

        const $tbody = $existingTable.find(`tbody.${cls.rcTableBody}`);
        $tbody.html("");

        for (const revision of page.revisions) {
          revision.createRevisionTableRow($tbody);
        }

        // FIXME: In user mode, own edits (reverts, etc.) are not displayed
      }

      /**
       * Display the "Loading..." overlay indicating that a request is currently processed and the user should wait
       * @param {Object} options
       * @param {boolean} options.showEdited whether to show (true) or hide (false) the "has been edited" version of the overlay
       * @param {boolean} options.showLoading whether to show (true) or hide (false) the overlay
       */
      static showLoadingOverlay({ showEdited = false, showLoading = true }) {
        $(`div.${cls.areaRc}, div#${ids.overlayLoading}`).toggleClass(cls.loading, showLoading);
        $(`div.${cls.areaRc}, div#${ids.overlayLoading}`).toggleClass(cls.loadingEdited, showEdited);
      }
    }

    /**
     * Factory for UI elements and dialogs
     */
    class UiFactory {
      /**
       * Provide the common options to pass to a prompt() call for the summary of any edit action
       * @param {Revision} revision revision from which tags should be read // TODO: Support edit series
       */
      static getEditSummaryPromptOptions(revision) {
        const options = [];

        // Static texts
        for (const item of [
          mw.msg("orcSummaryNoImprovement"),
          mw.msg("orcSummaryNoSource"),
          mw.msg("orcSummaryNoSubjectChange"),
          mw.msg("orcSummaryVandalism"),
          mw.msg("orcSummaryWrongLanguage")
        ].sort()) {
          options.push({ data: item });
        }

        // Tags of the revision
        if (revision !== undefined) {
          for (const tag of revision.tags.sort()) {
            const tagDef = orcData.tags.find(Util.findTag, tag);

            if (tagDef !== undefined && tagDef.label !== undefined &&  tagDef.label !== "") {
              options.push({ data: tagDef.label });
            } else {
              options.push({ data: tag });
            }
          }
        }

        return {
          $overlay: true,
          maxLength: orcConst.editSummaryLength,
          options: options,
          placeholder: mw.msg("orcEditSummaryPlaceholder")
        };
      }

      /**
       * Prepare the UI to display a page and its revisions
       * @param {number} pageId ID of the page to be loaded
       */
      static createRcTable(pageId) {
        const $ul = $("<ul>", { class: `${cls.pageContainer} ${cls.rcTableHead}` });
        // TODO: (Optional) namespace-coloring of the page header background?
        const $tbody = $("<tbody>", { class: cls.rcTableBody });

        const $rcTable = $("<table>", {
          class: `${cls.areaAll} ${cls.areaRc}`,
          id: `${ids.idPageTable}${pageId}`,
        })
          .append($("<thead>")
            .append($("<tr>")
              .append($("<td>", { colspan: orcConst.colsRcTable })
                .append($ul))))
          .append($tbody);

        return $rcTable;
      }

      /**
       * Create an edit button (= undo, restore or restore previous)
       * @param {Object} options
       * @param {string} options.confirmMsg i18n text for the confirmation dialog
       * @param {boolean} options.doConfirm whether the action should get confirmed
       * @param {string} options.icon OOjs UI icon name for the button
       * @param {string} options.label label text for the button
       * @param {string} options.log i18n text for the log message
       * @param {Revision} options.revision reference revision used in log
       * @param {string} options.title title/info text for the button
       * @param {number} options.undo revision ID of the latest revision to be undone
       * @param {number} options.undoAfter revision ID of the revision for which the changes after it should be undone
       */
      static getButtonEdit({ confirmMsg, doConfirm, icon, label, log, revision, title, undo, undoAfter }) {
        const btn = new OO.ui.ButtonWidget({
          flags: "destructive",
          icon: icon,
          label: new OO.ui.HtmlSnippet(label),
          title: title
        });

        btn.on("click", () => {
          let autoSummary;

          if (undoAfter !== undefined) {
            // No autosummary for restore actions, so create one manually
            autoSummary = mw.msg("orcSummaryRestored", undoAfter);
          }

          if (doConfirm) {
            UiFactory.prompt(confirmMsg, {
              revision: revision,
              size: "large",
              textInput: UiFactory.getEditSummaryPromptOptions(revision),
              title: label
            }).done(result => {
              if (result !== null) {
                Actions.edit({
                  btn: btn,
                  log: log,
                  revision: revision,
                  summary: (result === "") ? autoSummary : result,
                  undo: undo,
                  undoAfter: undoAfter
                });
              }
            });
          } else {
            Actions.edit({
              btn: btn,
              log: log,
              revision: revision,
              summary: autoSummary,
              undo: undo,
              undoAfter: undoAfter
            });
          }
        });

        return btn;
      }

      /**
       * Creates a "Next" buttons
       * @param {boolean} primary whether this button should get marked as primary button and get an access key
       */
      static getButtonNext(primary) {
        const btnNext = new OO.ui.ButtonWidget({
          flags: "progressive",
          icon: "next",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnNext")),
          title: mw.msg("orcBtnNextInfo")
        });

        if (primary) {
          btnNext.setFlags("primary");
          btnNext.setAccessKey("y");
        }

        btnNext.on("click", () => Pagination.showNextPageOrLoadMore());

        return btnNext;
      }

      /**
       * Create a "Patrol" button
       * @param {Revision} revision revision to be patrolled
       */
      static getButtonPatrol(revision) {
        const btnPatrol = new OO.ui.ButtonWidget({
          classes: [ cls.buttonPatrol ],
          icon: "check",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnPatrol")),
          title: mw.msg("orcBtnPatrolInfo")
        });

        btnPatrol.on("click", () => {
          if (orcConfig.confirmPatrol) {
            OO.ui.confirm(mw.msg("orcBtnPatrolConfirm")).done(confirmed => {
              if (confirmed) {
                Actions.patrol(revision);
              }
            });
          } else {
            Actions.patrol(revision);
          }
        });

        if (! orcData.userRights.has("patrol")) {
          btnPatrol.setDisabled(true);
          btnPatrol.setTitle(mw.msg("orcBtnDisabledRights"));
        }

        return btnPatrol;
      }

      /**
       * Create a "Revert" button
       * Note: In user mode, the button will always be displayed, as it is unknown then if newer revisions exist
       * @param {Revision} revision revision to be reverted
       */
      static getButtonRevert(revision) {
        const btnRevert = new OO.ui.ButtonWidget({
          flags: "destructive",
          icon: "clear",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnRevert", revision.page.countMostRecentEditSeriesSize())),
          title: mw.msg("orcBtnRevertInfo")
        });

        if (! orcData.userRights.has("rollback")) {
          btnRevert.setDisabled(true);
          btnRevert.setTitle(mw.msg("orcBtnDisabledRights"));
        } else if (! revision.isNewestRevision()) {
          // TODO: This now is most always true for SingleEditMode - query recentchanges twice there, once with rctoponly option to get a chance to know which revisions are current?
          btnRevert.setDisabled(true);
          btnRevert.setTitle(mw.msg("orcBtnRevertInfoDisabledLast"));
        } else if (! revision.page.isEditFromMostRecentSeries(revision) || revision.page.isVirginPage()) {
          btnRevert.setDisabled(true);
          btnRevert.setTitle(mw.msg("orcBtnRevertInfoDisabledOlder"));
        } else if (revision.page.revisions.length > revision.page.countMostRecentEditSeriesSize() && revision.page.revisions[revision.page.countMostRecentEditSeriesSize()].wasRestoredByRevisionIndex() === 0) {
          btnRevert.setDisabled(true);
          btnRevert.setTitle(mw.msg("orcBtnRevertInfoDisabledReverted"));
        } else {
          // TODO: Highlight edit to be reverted when confirmation dialog is shown
          btnRevert.on("click", () => {
            if (orcConfig.confirmRevert) {
              UiFactory.prompt(mw.msg("orcBtnRevertConfirm"), {
                revision: revision,
                size: "large",
                textInput: UiFactory.getEditSummaryPromptOptions(revision),
                title: mw.msg("orcBtnRevertConfirmTitle", revision.page.countMostRecentEditSeriesSize(), revision.user)
              }).done(result => {
                if (result !== null) {
                  Actions.revert({
                    $button: btnRevert.$element,
                    revision: revision,
                    summary: result
                  });
                }
              });
            } else {
              Actions.revert({
                $button: btnRevert.$element,
                revision: revision
              });
            }
          });
        }

        return btnRevert;
      }

      /**
       * Returns the current window manager, i.e. the one with the highest level that is used currently
       * @return {OO.ui.WindowManager} current window manager
       */
      static getCurrentWindowManager() {
        return (orcObj.windowManagerSecondLevel.getCurrentWindow() !== null) ? orcObj.windowManagerSecondLevel : orcObj.windowManagerFirstLevel;
      }

      /**
       * Get a FieldLayout with buttons, styled for an action dialog and sorted by button label
       * @param {Object[]} buttons list of buttons to be shown
       */
      static getDialogButtonLayout(buttons) {
        const fieldLayouts = [];

        buttons.sort((btn1, btn2) => btn1.getLabel().toString().localeCompare(btn2.getLabel().toString()));

        for (const button of buttons) {
          fieldLayouts.push(new OO.ui.FieldLayout(button, {
            align: "top",
            help: button.getTitle(),
            notices: button.isDisabled() ? [ button.getTitle() ] : undefined
          }));
        }

        return fieldLayouts;
      }

      /**
       * Get a list element for the given flag
       * @param {Object} flag flag object as defined in orcFlags constant
       * @return {string} HTML code
       */
      static getFlag(flag) {
        return `<li class="${(flag.important) ? cls.flagsTagsHighlightFlag : ""}" title="${mw.msg(flag.title)}">${mw.msg(flag.symbol)}</a>`;

        // TODO: orcFlags & Co: Replace "important" flag by "class" definition, then define individual styles in CSS
      }

      /**
       * Create a FieldLayout with a NumberInputWidget to set a specific orcConfig setting
       * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.FieldLayout}
       * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.NumberInputWidget}
       * @param {Object} options
       * @param {string} options.label label to be shown next to the input
       * @param {number} options.max maximum allowed value
       * @param {number} options.min minimum allowed value
       * @param {string} options.setting name of the setting to be adjusted
       * @param {string} options.title help text
       */
      static getSettingsNumberField({ label, max, min, setting, title }) {
        const input = new OO.ui.NumberInputWidget({
          isInteger: true,
          max: max,
          min: min,
          value: orcConfig[setting],
        });

        input.on("change", value => {
          orcConfig[setting] = value;
        });

        return new OO.ui.FieldLayout(input, {
          align: "top",
          help: title,
          label: label
        });
      }

      /**
       * Create a FieldLayout with a ToggleSwitchWidget to set a specific orcConfig setting
       * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.FieldLayout}
       * @see {@link https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ToggleSwitchWidget}
       * @param {Object} options
       * @param {string} options.label label to be shown next to the input
       * @param {string} options.setting name of the setting to be adjusted
       * @param {string} options.title help text
       */
      static getSettingsToggleSwitch({ label, setting, title }) {
        const toggle = new OO.ui.ToggleSwitchWidget({ value: orcConfig[setting] });

        toggle.on("change", value => {
          orcConfig[setting] = value;
        });

        return new OO.ui.FieldLayout(toggle, {
          align: "top",
          help: title,
          label: label
        });
      }

      /**
       * Open a dialog (in the next available level of window manager)
       * @param {Object} options
       * @param {Page|Revision|string} options.context context for the dialog (page, revision or user name)
       * @param {OO.ui.Window} options.page dialog page
       */
      static openDialog({ context, page }) {
        const manager = (orcObj.windowManagerFirstLevel.getCurrentWindow() === null) ? orcObj.windowManagerFirstLevel : orcObj.windowManagerSecondLevel;

        if (context !== undefined) {
          manager.getWindow(page).then(thisPage => thisPage.setContext(context));
        }

        manager.openWindow(page);

        // TODO: Reset the dialogs to the first page when reopening?
      }

      /**
       * Re-implementation of OO.ui.prompt for two reasons:
       * - don't autofocus the input field, as this is nasty on touch devices
       * - use a ComboBoxInputWidget rather than a text field to allow giving suggestions
       * @param {jQuery|string} text Message text to display
       * @param {Object} [options] Additional options, see OO.ui.MessageDialog#getSetupProcess
       * @param {Revision} [options.revision] Revision to be reverted/edited // TODO: Support edit series
       * @param {Object} [options.textInput] Additional options for text input widget, see OO.ui.ComboBoxInputWidget
       * @return {jQuery.Promise} Promise resolved when the user closes the dialog
       */
      static prompt(text, options) {
        const manager = OO.ui.getWindowManager();

        const textInput = new OO.ui.ComboBoxInputWidget((options && options.textInput) || {});
        const textField = new OO.ui.FieldLayout(textInput, {
          align: "top",
          label: text
        });

        // TODO: Use revision to display a diff (of revision itself or of expected revert?)

        const instance = manager.openWindow("message", $.extend({
          message: textField.$element
        }, options));

        instance.opened.then(() => {
          textInput.on("enter", () => manager.getCurrentWindow().close({ action: "accept" }));
        });

        return instance.closed.then(data => (data !== undefined && data.action === "accept") ? textInput.getValue() : null);
      }

      // TODO: Color-coding for age (Within last hour, 4 hours, 24 hours, 7 days, ...), or even a color gradient from 30 days to now -> green/yellow/red (e.g. 10min, 1h, 2h, 4h, 8h, 24h, 2d, 4d, 8d, 16d, 32d) -> use http://www.perbang.dk/rgbgradient/ and take the HSV gradient (inverse), 11 steps, ca. 00FFB0 to ca. FF0088
      // TODO: Also do time-coloring in the "Where are we now" counter, for better comparability
      /**
       * Create the HTML code that displays a timestamp
       * @param {string} timestamp ISO timestamp
       * @return {string} HTML code
       */
      static showTimestamp(timestamp) {
        const date = new Date(timestamp);

        return $("<span>", { title: `${timestamp} (${mw.message("ago", Util.timeSince(timestamp)).text()})` })
          .append($("<span>", { class: cls.timestampDate }).append(date.toLocaleDateString()).append($("<br>")))
          .append(date.toLocaleTimeString());
      }
    }

    /**
     * Factory that generates URLs for MediaWiki pages
     */
    class UrlFactory {
      /**
       * Get the URL for the action page to delete a page
       * @param {string} page full page title
       * @return {string} URL
       */
      static getUrlDelete(page) {
        return `${orcMw.urlScript}?title=${page}&action=delete`;
      }

      /**
       * Get the URL for the diff page between this revision and the one before it
       * @param {Revision} revision revision
       * @return {string} URL
       */
      static getUrlDiff(revision) {
        return `${orcMw.urlScript}?diff=${revision.revId}&curid=${revision.page.pageId}&oldid=${revision.oldRevId}&title=${mw.util.wikiUrlencode(revision.page.title)}`;
      }

      /**
       * Get the URL for the history of a page
       * @param {Page} page page
       * @return {string} URL
       */
      static getUrlHistory(page) {
        return `${orcMw.urlScript}?title=${mw.util.wikiUrlencode(page.title)}&curid=${page.pageId}&action=history`;
      }

      /**
       * Get the URL for a page (article, etc.)
       * @param {Page} page page
       * @return {string} URL
       */
      static getUrlPage(page) {
        return `${orcMw.urlServer}${mw.util.getUrl(page.title)}`;
      }

      /**
       * Get the URL for the action page to change the protection level of a page
       * @param {string} page full page title
       * @return {string} URL
       */
      static getUrlProtect(page) {
        return `${orcMw.urlScript}?title=${page}&action=protect`;
      }

      /**
       * Get the URL for the user page of a user
       * @param {string} user user name
       * @return {string} URL
       */
      static getUrlUser(user) {
        return `${orcMw.urlServer}${mw.util.getUrl(`User:${user}`)}`;
      }

      /**
       * Get the URL for the user contributions page of a user
       * @param {string} user user name
       * @return {string} URL
       */
      static getUrlUserContribs(user) {
        return `${orcMw.urlServer}${mw.util.getUrl(`Special:Contributions/${user}`)}`;
      }

      /**
       * Get the URL for the talk page of a user
       * @param {string} user user name
       * @return {string} URL
       */
      static getUrlUserTalk(user) {
        return `${orcMw.urlServer}${mw.util.getUrl(`User talk:${user}`)}`;
      }
    }

    /**
     * Utility class to group generic helper methods
     */
    class Util {
      /**
       * Extract the introduction from an ApiDataExtracts result in order to preview it
       * @param {Object} data ApiDataExtracts result
       */
      static getIntroduction(data) {
        const ellipsisLength = 3;
        const redirect = data.query.pages[Object.keys(data.query.pages)[0]].redirect;
        let extract = data.query.pages[Object.keys(data.query.pages)[0]].extract;

        if (extract.length <= ellipsisLength || redirect !== undefined) {
          extract = (redirect !== undefined) ? mw.msg("orcNoPreviewRedirect") : mw.msg("orcNoPreviewIntro");
          extract = `<i>${extract}</i>`;
        }

        return extract;
      }

      /**
       * Get the URL for the Recent Changes page - usually from the filterLists component,
       * but as a fallback (if user doesn't use new component) from the browser URL,
       * and as a last resort (we are on a user JavaScript page rather than a RC page) a hardcoded one
       */
      static getRcUrl() {
        let url;

        try {
          url = filterLists.getURL(false);
        } catch (e) {
          if (orcMw.isRcPage) {
            url = window.location.href;
          } else {
            url = `https:${orcMw.urlScript}?title=Special:RecentChanges?hidepatrolled=1&namespace=0`;
          }

          // TODO: A prompt could be used to let the user enter a custom URL
        }

        return url;
      }

      /**
       * Load the stored labels, descriptions etc. for a Wikibase entity from cache
       * @param {Object} options
       * @param {string} options.title title of the entity which should be translated
       * @param {string} options.type labels, descriptions or aliases
       */
      static getFromLabelCache({ title, type }) {
        if (! orcData.labelCache.has(title) || ! orcData.labelCache.get(title).has(type) || ! orcData.labelCache.get(title).get(type).has("selected")) {
          return {
            class: cls.entityLangOwn,
            lang: orcMw.userLang,
            value: (type === "labels") ? title : ""
          };
        }

        const lang = orcData.labelCache.get(title).get(type).get("selected");

        return {
          class: (lang === orcMw.userLang) ? cls.entityLangOwn : cls.entityLangForeign,
          lang: lang,
          value: orcData.labelCache.get(title).get(type).get(lang)
        };
      }

      /**
       * Select the best matching values from a label/description/alias API query, according to the language fallback chain
       * @param {Object} data JSON object with API response data
       */
      static fillLabelCache(data) {
        for (const title of Object.keys(data.entities)) {
          orcData.labelCache.set(title, new Map());
          const thisCache = orcData.labelCache.get(title);
          thisCache.set("languages", new Set());

          for (const type of [ "labels", "descriptions", "aliases" ]) {
            thisCache.set(type, new Map());

            // Collect all labels, descriptions, aliases and put them in a map
            if (data.entities[title][type] !== undefined) {
              for (const lang of Object.keys(data.entities[title][type])) {
                let value;

                thisCache.get("languages").add(lang);

                if (Array.isArray(data.entities[title][type][lang])) {
                  // Multiple values for aliases - concatenate with "|"
                  const valueArray = [];

                  for (const values of data.entities[title][type][lang]) {
                    valueArray.push(values.value.trim());
                  }

                  value = valueArray.join(" | ");
                } else {
                  value = data.entities[title][type][lang].value.trim();
                }

                thisCache.get(type).set(lang, value);
              }
            }

            // Get best matching label and description according to the language fallback chain, and their respective languages
            for (const lang of orcData.fallBackLangs) {
              if (thisCache.get(type).has(lang)) {
                thisCache.get(type).set("selected", lang);

                break;
              }
            }

            // If no text was found in the fallback languages, take first existing language
            if (! thisCache.get(type).has("selected") && thisCache.get(type).size > 0) {
              thisCache.get(type).set("selected", thisCache.get(type).keys().next().value);
            }
          }

          // Show aliases in same language as labels
          thisCache.get("aliases").set("selected", thisCache.get("labels").get("selected"));
        }
      }

      /**
       * Find a specific tag in the orcData.tags list, to be used as a Array.prototype.find() callback method
       * @param {string} element tag name to search for
       */
      static findTag(element) {
        return (element.name === this);
      }

      /**
       * Calculates the "time ago"
       * @param {string} date ISO timestamp
       * @return {string} e.g. "2 minutes ago"
       */
      static timeSince(date) {
        const seconds = Math.floor((new Date() - new Date(date)) / orcConst.timeMilPerSec);
        let interval = Math.floor(seconds / (orcConst.timeSecPerMin * orcConst.timeMinPerHour * orcConst.timeHrsPerDay));

        if (interval > 1) {
          return mw.message("days", interval).text();
        }

        interval = Math.floor(seconds / (orcConst.timeSecPerMin * orcConst.timeMinPerHour));

        if (interval > 1) {
          return mw.message("hours", interval).text();
        }

        interval = Math.floor(seconds / orcConst.timeSecPerMin);

        if (interval > 1) {
          return mw.message("minutes", interval).text();
        }

        return mw.message("seconds", seconds).text();
      }

      /**
       * Calculate a numeric hash for a string (from Java's String.Util.hashCode())
       * @param {string} str input string
       * @return {number} hash integer
       */
      static hashCode(str) {
        const thirtyTwoBit = 5;
        let hash = 0;

        for (let i = 0; i < str.length; i++) {
          hash = str.charCodeAt(i) + ((hash << thirtyTwoBit) - hash);
        }

        return hash;
      }

      /**
       * Assigns a hex color code to any number
       * @param {number} value input integer
       * @return {string} 6-digit hex number
       */
      static intToRGB(value) {
        const bitMask = 0x00FFFFFF;
        const digits = 6;
        const hexRadix = 16;
        const pattern = (value & bitMask).toString(hexRadix).toUpperCase();

        return "00000".substring(0, digits - pattern.length) + pattern;
      }

      /**
       * Remove a value from an array
       * @param {Object} options
       * @param {*[]} array the array to be modified
       * @param {*} value the value to be removed
       */
      static removeFromArray(array, value) {
        const index = array.indexOf(value);

        if (index !== -1) {
          array.splice(index, 1);
        }
      }
    }

    /**
     * Utility class to group all methods used to inject content loaded via AJAX
     */
    class Ajax {
      /**
       * Load the user's unpatrolled edits
       * @param {string} user user
       */
      static loadUserContribs(user) {
        $(`div.${cls.areaUserRc}`).html($.createSpinner({
          size: "large",
          type: "block"
        }));

        orcObj.api.get(new ApiDataRc({
          rcshow: "!patrolled", // TODO: Ignore if no patrol right? Correct would be to only show the "All recent edits" section then (not yet implemented) and hide this one
          user: user
        })).done(data => {
          if (! data.hasOwnProperty("error") && data.query !== undefined && data.query.recentchanges !== undefined) {
            const revMap = new Map();
            orcData.userModePages = [];

            for (const queryRevision of data.query.recentchanges) {
              if (! revMap.has(queryRevision.pageid)) {
                const page = new Page({
                  isSingleEditMode: true,
                  pageId: queryRevision.pageid,
                  title: queryRevision.title
                });

                revMap.set(queryRevision.pageid, page);
                orcData.userModePages.push(page);
              }

              revMap.get(queryRevision.pageid).addRevision(new Revision({
                comment: queryRevision.comment,
                isMinor: queryRevision.isMinor,
                isPatrolled: queryRevision.patrolled !== undefined,
                oldRevId: queryRevision.old_revid,
                oresScores: queryRevision.oresscores,
                page: revMap.get(queryRevision.pageid),
                parsedComment: queryRevision.parsedcomment,
                revId: queryRevision.revid,
                sha1: queryRevision.sha1,
                tags: queryRevision.tags,
                timestamp: queryRevision.timestamp,
                user: queryRevision.user
              }));
            }

            const $div = $(`div.${cls.areaUserRc}`);
            $div.html("");

            if (revMap.size > 0) {
              for (const page of revMap.values()) {
                $div.append(UiFactory.createRcTable(page.pageId));
                Pagination.loadRcTable(page);
              }
            } else {
              Pagination.showLoadingOverlay({ showLoading: false });
              $(`div.${cls.areaUserRc}`).append(mw.msg("orcNoResult"));
            }
          } else {
            Logger.logError({ data: data });
          }
        }).fail((jqXHR, textStatus) => Logger.logError({
          data: textStatus,
          msg: jqXHR
        }));
      }

      // Note: Cannot be moved in Page class, or at least doesn't have Page context then, as it's being called as a jQuery callback method
      // TODO: Yes, it can be moved -> context, baby!
      /**
       * Show the Wikidata label and description after they have been loaded via AJAX
       * @param {Object} options
       * @param {Object} options.data JSON object with API response data
       * @param {Object} options.pageId ID of the page
       */
      static injectLabels({ data, pageId }) {
        const title = Object.keys(data.entities)[0];
        let textWasShortened = false;
        const $labelDescDiv = $(`div#${ids.idPageLabel}${pageId}`);

        Util.fillLabelCache(data);
        const thisCache = orcData.labelCache.get(title);

        // Add text to page header
        const classLang = {
          aliases: cls.entityAliasLang,
          descriptions: cls.entityDescLang,
          labels: cls.entityLabelLang
        };
        const classText = {
          aliases: cls.entityAliasText,
          descriptions: cls.entityDescText,
          labels: cls.entityLabelText
        };
        const txtNone = {
          descriptions: "orcNoDescription",
          labels: "orcNoLabel",
        };

        const $ul = $("<ul>");

        for (const type of [ "labels", "aliases", "descriptions" ]) {
          const $li = $("<li>", { class: classText[type] });
          const lang = thisCache.get(type).get("selected");
          const text = thisCache.get(type).get(lang);

          if (txtNone[type] === undefined && (lang === undefined || text === undefined)) {
            // Aliases: If not present, just don't show
            continue;
          } else if (lang === undefined) {
            // Rest: If not present, show placeholder
            $li.addClass(cls.entityNoText);
            $li.append(mw.msg(txtNone[type]));
          } else {
            if ((type === "descriptions" || type === "aliases") && text.length > orcConst.descLimit) {
              textWasShortened = true;
              $li.prop("title", text);
              $li.append(mw.format("$1$2", text.substring(0, orcConst.descLimit - 1), "…"));
            } else {
              $li.append(text);
            }

            const $spanLang = $("<span>", { class: classLang[type] });
            $spanLang.addClass(lang !== orcMw.userLang ? cls.entityLangForeign : cls.entityLangOwn);
            $spanLang.append(`[${lang}]`);

            $li.append($spanLang);
          }

          $ul.append($li);
        }

        // If there are any values not shown (or not completely shown): Add table popup
        // Check for > 2 as the cache will have <lang> and "selected" as keys if there is one language
        if (textWasShortened || thisCache.get("labels").size > 2 || thisCache.get("descriptions").size > 2 || thisCache.get("aliases").size > 2) {
          const $table = $("<table>", { class: cls.entityLabelsTable });

          for (const lang of Array.from(thisCache.get("languages").values()).sort()) {
            const $tr = $("<tr>");
            $tr.append($("<td>").append(lang));
            $tr.append($("<td>").append(thisCache.get("labels").get(lang)));
            $tr.append($("<td>").append(thisCache.get("descriptions").get(lang)));
            $tr.append($("<td>").append(thisCache.get("aliases").get(lang)));
            $table.append($tr);
          }

          const btnMore = new OO.ui.PopupButtonWidget({
            classes: [ cls.buttonSmall, cls.entityLabelsMore ],
            framed: false,
            icon: "ellipsis",
            popup: {
              $content: $table,
              align: "center",
              padded: true,
              width: 600
            },
            title: mw.msg("orcLabelShowMore")
          });

          $ul.append(btnMore.$element);
        }

        $labelDescDiv.html($ul);
      }

      /**
       * Show the Wikidata sitelinks after they have been loaded via AJAX
       * @param {Object} options
       * @param {Object} options.data JSON object with API response data
       * @param {Object} options.pageId ID of the page
       */
      static injectSitelinks({ data, pageId }) {
        const title = Object.keys(data.entities)[0];
        const sitelinks = data.entities[title].sitelinks;
        const $div = $(`div#${ids.idPageSitelink}${pageId}`);

        if (sitelinks === undefined || Object.keys(sitelinks).length === 0) {
          $div.html(mw.msg("orcNoSitelinks"));

          return;
        }

        const lis = [];
        const sitelinksOrder = [];
        const sites = Object.keys(sitelinks);

        // Sort sitelinks by language importance (only check Wikipedia, i.e. "<lang>wiki")
        for (const lang of orcData.fallBackLangs) {
          if (sites.includes(`${lang}wiki`)) {
            sitelinksOrder.push(`${lang}wiki`);
          }

          // TODO: Rather than just joining with "wiki", use mw.config.wbSiteDetails to find any wikis with the correct language
        }

        // Add rest of sites
        for (const site of sites) {
          if (! sitelinksOrder.includes(site)) {
            sitelinksOrder.push(site);
          }
        }

        for (const site of sitelinksOrder) {
          const btnPreview = new OO.ui.PopupButtonWidget({
            classes: [ cls.buttonSmall ],
            framed: false,
            icon: "comment",
            id: `${ids.idPageSitelinkPreviewButton}${site}${title}`,
            popup: {
              $content: $("<div></div>", { id: `${ids.idPageSitelinkPreviewContent}${site}${title}` }).html($.createSpinner({
                size: "small",
                type: "block"
              })),
              align: "forwards",
              padded: true,
              position: "after"
            },
            title: mw.msg("orcLabelShowPreview")
          });
          // FIXME: In user mode, sitelink previews don't always work

          btnPreview.getPopup().on("toggle", show => {
            const $divPreview = $(`#${ids.idPageSitelinkPreviewContent}${site}${title}`);

            if (show && $divPreview.length > 0 && ! $divPreview.hasClass(cls.loaded)) {
              Ajax.injectPreview({
                $div: $divPreview,
                site: site,
                title: sitelinks[site].title
              });
            }
          });

          const $li = $("<li>");
          $li.append(`${site}: `);
          $li.append($("<a>", { href: wikibase.sites.getSite(site).getUrlTo(sitelinks[site].title) }).append(sitelinks[site].title));
          $li.append(btnPreview.$element);

          // FIXME: Preview popup is sometimes opened on the wrong position, especially if resizing of the elements took long
          // FIXME: Sitelink popups can't be read when sitelinks are positioned too far right
          // TODO: Attach preview popup link to sitelinks in diff, too, and a similar popup for external links as well

          lis.push($li);
        }

        const $ul = $("<ul>");

        if (lis.length <= orcConst.sizeSitelinks) {
          $ul.append(lis);
        } else {
          const $ulLong = $("<ul>");
          $ulLong.append(lis);

          const btnMore = new OO.ui.PopupButtonWidget({
            classes: [ cls.buttonSmall ],
            framed: false,
            icon: "ellipsis",
            popup: {
              $content: $ulLong,
              align: "center",
              padded: true
            },
            title: mw.msg("orcLabelShowMore")
          });

          $ul.append(lis.slice(0, orcConst.sizeSitelinks - 1));
          $ul.append(btnMore.$element);
        }

        $div.html($ul);
      }

      /**
       * Load the sitelink preview and inject it into the sitelink preview popup
       * @param {Object} options
       * @param {string} options.site project (DB name) to be queried
       * @param {string} options.title title of the page to be previewed
       * @param {Object} options.$div JSON object used to inject the content to
       */
      static injectPreview({ site, title, $div }) {
        const api = new mw.ForeignApi(wikibase.sites.getSite(site).getApi(), { anonymous: true });
        api.get(new ApiDataExtracts({
          external: true,
          title: title
        })).done(data => {
          // Get introduction
          $div.addClass(cls.loaded);
          $div.html(Util.getIntroduction(data));
        }).fail((jqXHR, textStatus) => Logger.logError({
          data: textStatus,
          msg: jqXHR
        }));
      }

      /**
       * Show the Wikidata statements (claims) after they have been loaded via AJAX
       * @param {Object} options
       * @param {Object} options.data JSON object with API response data
       * @param {Object} options.pageId ID of the page
       */
      static injectStatements({ data, pageId }) {
        const title = Object.keys(data.entities)[0];
        const claims = data.entities[title].claims;
        const lis = [];
        const $div = $(`div#${ids.idPageStatement}${pageId}`);
        const $table = $("<table>", { class: cls.entityLabelsTable });

        if (claims === undefined || Object.keys(claims).length === 0) {
          $div.html(mw.msg("orcNoStatements"));

          return;
        }

        const values = Ajax.getClaimValues({
          data: data,
          onlyItems: false,
          translateLabels: true
        });

        for (const prop of values.keys()) {
          for (let value of values.get(prop)) {
            const labelProp = Util.getFromLabelCache({
              title: prop,
              type: "labels"
            }).value;

            const $tr = $("<tr>");
            $tr.append($("<td>").append(labelProp));
            $tr.append($("<td>").append(prop));

            if (value.hasOwnProperty("label")) {
              let newValue = `${value.label} <span class="${value.labelClass}">[${value.labelLang}]</span>`;

              if (value.desc !== "" && value.desc !== undefined) {
                if (value.desc.length > orcConst.descLimit) {
                  newValue += `<br /><span title="${value.desc}" class="${cls.statementDescriptionText}">${value.desc.substring(0, orcConst.descLimit - 1)}… <span class="${value.descClass}">[${value.descLang}]</span></span>`;
                } else {
                  newValue += `<br /><span class="${cls.statementDescriptionText}">${value.desc} <span class="${value.descClass}">[${value.descLang}]</span></span>`;
                }
              }

              $tr.append($("<td>").append(value.label));
              $tr.append($("<td>").append(value.labelLang));
              $tr.append($("<td>").append(value.desc));
              $tr.append($("<td>").append(value.descLang));

              value = newValue;
            } else {
              $tr.append($("<td>", { colspan: 4 }).append(value));
            }

            lis.push(`<li>${labelProp}: ${value}</li>`);

            $table.append($tr);
          }
        }

        if (lis.length === 0) {
          $div.html(mw.msg("orcNoStatements"));

          return;
        }

        const btnMore = new OO.ui.PopupButtonWidget({
          classes: cls.buttonSmall,
          framed: false,
          icon: "ellipsis",
          popup: {
            $content: $table,
            align: "center",
            padded: true,
            width: 600
          },
          title: mw.msg("orcLabelShowMore")
        });

        const $ul = $("<ul>");

        if (lis.length <= orcConst.sizeStatements) {
          $ul.append(lis);
        } else {
          $ul.append(lis.slice(0, orcConst.sizeStatements - 1));
          $ul.append(btnMore.$element);
        }

        $div.html($ul);

        // TODO: Rather than define important properties in orcProperties, rely on https://www.wikidata.org/wiki/MediaWiki:Wikibase-SortedProperties sorting and just show first x ones + "more" (as they are known anyway)?
      }

      /**
       * Extract the Wikibase  values for claims from an API result, possibly translated
       * @param {Object} options
       * @param {string} options.data API result data
       * @param {string} options.onlyItems whether to ignore any values with data types other than item
       * @param {string} options.translateLabels whether to use the labels and descriptions rather than the Qids
       */
      static getClaimValues({ data, onlyItems, translateLabels }) {
        const title = Object.keys(data.entities)[0];
        const claims = data.entities[title].claims;
        const values = new Map();

        for (const prop of orcProperties) {
          if (claims !== undefined && claims[prop] !== undefined) {
            values.set(prop, []);

            for (const multivalue of claims[prop]) {
              if (multivalue.mainsnak !== undefined && multivalue.mainsnak.datavalue !== undefined && multivalue.mainsnak.datavalue.value !== undefined) {
                let value;

                if (multivalue.mainsnak.datatype === "wikibase-item") {
                  value = multivalue.mainsnak.datavalue.value.id;

                  if (translateLabels) {
                    const desc = Util.getFromLabelCache({
                      title: value,
                      type: "descriptions"
                    });

                    const label = Util.getFromLabelCache({
                      title: value,
                      type: "labels"
                    });

                    value = {
                      desc: desc.value,
                      descClass: desc.class,
                      descLang: desc.lang,
                      label: label.value,
                      labelClass: label.class,
                      labelLang: label.lang
                    };
                  }
                } else if (onlyItems) {
                  continue;
                } else if (multivalue.mainsnak.datatype === "time") {
                  // Timestamp in Wikibase's own format, similar to ISO 8601, only AD/BC information added and month/year may be 00
                  const timestamp = multivalue.mainsnak.datavalue.value.time.replace(/-00/g, "-01");

                  // Precision tells whether the date is exact, only month+year, year, decade, ...
                  const precision = multivalue.mainsnak.datavalue.value.precision;

                  // Before/after gives tolerance range (in the unit defined by precision)
                  const beforeAfter = Math.max(multivalue.mainsnak.datavalue.value.before, multivalue.mainsnak.datavalue.value.after);

                  // Format date as exactly as precision allows
                  value = new Date(timestamp.substring(1)).toLocaleDateString(undefined, {
                    month: (precision === dataValues.TimeValue.getPrecisionById("MONTH")) ? "long" : undefined,
                    year: (precision <= dataValues.TimeValue.getPrecisionById("MONTH")) ? "numeric" : undefined
                  });

                  // If precision is decade, century, ... don't try to translate, but simply give year + precision
                  if (precision < dataValues.TimeValue.getPrecisionById("YEAR")) {
                    value = mw.msg("orcTimePrecision", value, dataValues.TimeValue.PRECISIONS[precision].text);
                  }

                  // Add AD / BC information
                  if (timestamp.charAt(0) === "-") {
                    value = mw.msg("orcTimeBc", value);
                  }

                  if (beforeAfter > 0) {
                    value = mw.msg("orcTimeBeforeAfter", value, beforeAfter);
                  }
                } else {
                  value = JSON.stringify(multivalue.mainsnak.datavalue.value);
                }

                values.get(prop).push(value);
              } else if (! onlyItems) {
                if (multivalue.mainsnak.snaktype === "novalue") {
                  values.get(prop).push(mw.msg("orcLabelSnaktypeNoValue"));
                } else if (multivalue.mainsnak.snaktype === "somevalue") {
                  values.get(prop).push(mw.msg("orcLabelSnaktypeSomeValue"));
                } else {
                  values.get(prop).push(multivalue.mainsnak.snaktype);
                }
              }
            }

            if (values.get(prop).length === 0) {
              values.set(prop, undefined);
            }
          }
        }

        return values;
      }

      /**
       * Show a diff after it has been loaded via AJAX
       * @param {Object} data JSON object with API response data
       */
      static injectDiff(data) {
        if (! data.hasOwnProperty("error") && data.compare !== undefined) {
          // FIXME: If revisions are hidden, diff loading is endless

          const styleModules = [
            // Base style (always assuming Vector skin for consistent layout within the tool)
            "ext.math.styles",
            "site.styles",
            "mediawiki.diff.styles",
            "mediawiki.skinning.interface",
            "mediawiki.widgets.styles",
            "skins.vector.styles",
            "wikibase.common",
            // User styles (only local) // TODO: Load global user styles from meta, too? Skip user styles at all?
            "user.styles"
          ];

          const css = `table.${cls.mwDiff} {
            font-size: 80%;
            border: 1px dotted lightgrey;
          }`;

          // Also for the URL, always assume Vector skin
          const styleUrl = `${orcMw.urlLoad}?modules=${styleModules.join("%7C")}&only=styles&skin=vector&user=${orcMw.userName}`;
          let content = mw.msg("orcNoDiff");

          if (data.compare["*"] !== "") {
            const diff = data.compare["*"].replace(/'/g, "&#39;");
            // Diff does not apply full Vector styling, as the "content/mw-body" diff is missing. Without, the styling fits better here, though.
            const src = `<html><head><link rel="stylesheet" type="text/css" href="${styleUrl}" /><style>${css}</style><base target="_blank" /></head><body><table class="${cls.mwDiff} ${cls.mwDiffContentAlignLeft}">${diff}</table></body></html>`;
            content = `<iframe srcdoc='${src}'></iframe>`;
          }

          $(`div#${ids.idRevDiffPreview}${data.compare.torevid}`).html(content);
        } else {
          Logger.logError({ data: data });
        }

        // TODO: Include Schnark's diff view? (Possibly add a button next to the summary to toggle, similar to Schnark's original way; in the end, this button could toggle diff view, Schnark view, Yair Rand view) [https://de.wikipedia.org/wiki/Benutzer:Schnark/js/diff.js]
      }

      /**
       * Show the patrol action button after the patrolling status has been checked via AJAX
       * @param {Revision} revision revision for which the button should be displayed
       */
      static injectPatrolButton(revision) {
        const $div = $(`div#${ids.idRevPatrolState}${revision.revId}`);

        if (revision.isPatrolled) {
          // Create checkmark(s)
          Ajax.addPatrolCheckmarks({
            $div: $div,
            revision: revision
          });
        } else {
          // Create patrol button
          $div.html(UiFactory.getButtonPatrol(revision).$element);
        }
      }

      /**
       * Show the ORES scores for the revision
       * @param {Revision} revision revision for which the information should be displayed
       */
      static injectOresScores(revision) {
        if (revision.oresScores !== undefined && orcMw.oresThresh !== null) {
          const $flags = $(`#${ids.idRevTable}${revision.revId} ul.${cls.flagsTags}`);

          for (const category of Object.keys(revision.oresScores)) {
            const ratingClasses = [ `${cls.oresRating}${category}` ];

            if (orcMw.oresThresh[category] !== undefined) {
              for (const rating of Object.keys(orcMw.oresThresh[category])) {
                if (orcMw.oresThresh[category][rating] > 0 && revision.oresScores[category].true >= orcMw.oresThresh[category][rating]) {
                  ratingClasses.push(`${cls.oresRating}${category}-${rating}`);
                }
              }
            }

            $flags.append(`<li title="${revision.oresScores[category].true}" class="${ratingClasses.join(" ")}">ORES ${category}: ${revision.oresScores[category].true.toLocaleString(undefined, { style: "percent" })}</li>`);
          }
        }
      }

      /**
       * Replaces the patrol button/spinner with a checkmark, as the revision is patrolled
       * Changes checkmark if all revisions are patrolled
       * @param {Object} options
       * @param {Revision} options.revision revision that is to be marked as patrolled
       * @param {Object} options.$div jQuery object with the div containing the patrol button or spinner
       */
      static addPatrolCheckmarks({ revision, $div }) {
        revision.isPatrolled = true;

        // Use different colors for "this is patrolled" and "all are patrolled", use unicode icons rather than OOjs UI ones for better stylability
        if (revision.page.areAllRevisionsPatrolled()) {
          for (const rev of revision.page.revisions) {
            const $thisDiv = $(`div#${ids.idRevPatrolState}${rev.revId}`);

            $thisDiv.html("&#x2713;");
            $thisDiv.prop("title", mw.msg("orcLabelPatrolledAll"));
            $thisDiv.addClass(cls.patrolMarkAll);
          }
        } else {
          $div.html("&#x2713;");
          $div.prop("title", mw.msg("orcLabelPatrolled"));
          $div.addClass(cls.patrolMark);
        }
      }
    }

    /**
     * Utility class to group all methods related to actions performed by the user
     */
    class Actions {
      /**
       * Perform an edit (like restore, undo, ...)
       * @param {Object} options
       * @param {Object} options.btn button that was used
       * @param {string} options.log i18n text for the log message
       * @param {Revision} options.revision reference revision used in log
       * @param {string} options.summary summary to be used (auto-summary if undefined)
       * @param {number} options.undo revision ID of the latest revision to be undone
       * @param {number} options.undoAfter revision ID of the revision for which the changes after it should be undone
       */
      static edit({ btn, log, revision, summary, undo, undoAfter }) {
        orcObj.api.post(new ApiActionEdit({
          summary: summary,
          title: revision.page.title,
          undo: undo,
          undoAfter: undoAfter
        }))
          .done(data => {
            if (! data.hasOwnProperty("error")) {
              btn.setDisabled(true);
              btn.setTitle(log);

              if (data.edit.nochange === undefined) {
                Logger.logUndo({
                  msg: log,
                  revision: revision
                });
              } else {
                Logger.logError({
                  msg: mw.msg("orcLogNotUndone"),
                  revision: revision
                });
              }

              if (orcConfig.autoPatrol) {
                if (undoAfter === undefined) {
                  Actions.patrol(revision);
                } else {
                  // Patrol all revisions after restored one
                  for (const rev of revision.page.revisions) {
                    if (rev.revId > undoAfter) {
                      Actions.patrol(rev);
                    }
                  }
                }
              }

              if (orcConfig.reloadAfterAction && data.edit.nochange === undefined) {
                Pagination.reloadHistory({
                  hasBeenEdited: true,
                  page: revision.page
                });
              }
            } else {
              Logger.logError({ data: data });
            }
          }).fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));

        // TODO: Check out specialized API calls:
        // https://www.wikidata.org/w/api.php?action=help&modules=flow on Flow pages (different sub-actions for Flow headers, descriptions, etc.)
      }

      /**
       * Thank a user for their edit
       * @param {Revision} revision revision for which the thank should be send
       * @param {Object} btnThank button which was used
       */
      static thank(revision, btnThank) {
        orcObj.api.post(new ApiActionThank({ rev: revision.revId }))
          .done(data => {
            if (! data.hasOwnProperty("error")) {
              btnThank.setDisabled(true);
              btnThank.setTitle(mw.msg("orcLogThankedUser", revision.user));
              Logger.logMsg({
                msg: mw.msg("orcLogThankedUser", revision.user),
                revision: revision
              });

              if (orcConfig.autoPatrol) {
                Actions.patrol(revision);
              }
            } else {
              Logger.logError({ data: data });
            }
          }).fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));
      }

      /**
       * Patrol all displayed edits by the user for which the user mode currently is active
       */
      static patrolAllUser() {
        for (const page of orcData.userModePages) {
          Actions.patrolAllPage(page);
        }
      }

      /**
       * Patrol all displayed revisions of a page (if not done already)
       * @param {Page} page page for which revisions should be patrolled
       */
      static patrolAllPage(page) {
        for (const revision of page.revisions) {
          Actions.patrol(revision);
        }
      }

      /**
       * Patrol the revision (if not done already)
       * @param {Revision} revision revision that should be patrolled
       */
      static patrol(revision) {
        if (! revision.isPatrolled && orcData.userRights.has("patrol")) {
          orcObj.api.post(new ApiActionPatrol({ revId: revision.revId })).done(data => {
            if (! data.hasOwnProperty("error") && data.patrol !== undefined && data.patrol.rcid !== undefined) {
              Logger.logPatrol({ revision: revision });
              Ajax.addPatrolCheckmarks({
                $div: $(`div#${ids.idRevPatrolState}${revision.revId}`),
                revision: revision
              });
            } else {
              Logger.logError({ data: data });
            }
          }).fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));
        }
      }

      /**
       * Revert all displayed edits by the user for which the user mode currently is active
       * @param {string} summary custom edit summary (if empty, default summary will be used)
       */
      static revertAllUser(summary) {
        for (const page of orcData.userModePages) {
          Actions.revert({
            revision: page.revisions[0],
            summary: summary
          });
        }
      }

      /**
       * Revert the edit / edit series
       * @param {Object} options
       * @param {Revision} options.revision one of the revisions that should be reverted
       * @param {string} options.summary custom edit summary (if empty, default summary will be used)
       * @param {Object} options.$button jQuery object with the button that triggered the action and should be removed afterwards
       */
      static revert({ revision, summary, $button }) {
        orcObj.api.post(new ApiActionRevert({
          summary: (summary === "") ? undefined : summary,
          title: revision.page.title,
          user: revision.user
        }))
          .done(data => {
            if (! data.hasOwnProperty("error") && data.rollback !== undefined) {
              Logger.logUndo({
                msg: data.rollback.summary,
                revision: revision
              });

              if ($button !== undefined) {
                $button.remove();
              }

              if (orcConfig.reloadAfterAction) {
                Pagination.reloadHistory({
                  hasBeenEdited: true,
                  page: revision.page
                });
                // FIXME: Rollback blur coming late
              }
            } else {
              Logger.logError({ data: data });
            }
          }).fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));

        // TODO: After failed reverts, patrolled state of the latest edit series should be reloaded (or even the whole history)
      }
    }

    /**
     * Utility class to group methods used to initialize the tool
     */
    class InitTool {
      /**
       * Call the various methods to start/setup the tool
       */
      static initTool() {
        // Init objects
        orcObj.api = new mw.Api();
        orcObj.windowManagerFirstLevel = new OO.ui.WindowManager();
        orcObj.windowManagerSecondLevel = new OO.ui.WindowManager();
        orcData.startTime = new Date().toLocaleString();

        // Load MediaWiki messages
        orcObj.api.loadMessagesIfMissing(orcData.i18nMediaWiki).done(() => {
          // Load customized messages
          $.each(i18n, (key, value) => {
            if (value[orcMw.userLang] !== undefined) {
              mw.messages.set(key, value[orcMw.userLang]);
            } else {
              mw.messages.set(key, value.en);
            }
          });

          // Save rc filters (see https://www.mediawiki.org/wiki/Edit_Review_Improvements/New_filters_for_edit_review)
          $(".mw-rcfilters-ui-rcTopSectionWidget-topLinks-table, .mw-rcfilters-ui-filterWrapperWidget-bottom").remove();
          const $rcfilters = $(`.${cls.mwRcFilters}`);
          $rcfilters.appendTo("html");
          const $rcoverlay = $(`.${cls.mwRcOverlay}`);
          $rcoverlay.appendTo("html");

          InitTool.reorganizeFallBackLanguages();
          InitTool.loadPropertyLabels();
          InitTool.loadUserRights();
          InitTool.initToolPreparePage();
          InitTool.initDialogFilters({
            $rcfilters: $rcfilters,
            $rcoverlay: $rcoverlay
          });
          InitTool.initDialogHelp();
          InitTool.initDialogSkip();
          InitTool.initDialogUserActions();
          InitTool.initDialogPageActions();
          InitTool.initDialogRevisionActions();
          InitTool.initDialogSettings();

          // (Try to) add a confirmation dialog on closing if a dialog was opened (to avoid users closing the tool rather than the dialog)
          window.addEventListener("beforeunload", (e) => {
            if (orcObj.windowManagerFirstLevel.getCurrentWindow() !== null) {
              // Note that most browsers won't actually display this very message
              e.returnValue = mw.msg("orcCloseConfirm");

              return mw.msg("orcCloseConfirm");
            }
          });

          if ($("body.mw-rcfilters-enabled").length === 0) {
            Logger.logError({ msg: mw.msg("orcErrorRcfiltersMissing") });
          }

          // Disable RC Filters "Live Update", as this only triggers unnecessary requests here
          mw.config.set("StructuredChangeFiltersLiveUpdatePollingRate", 0);

          // TODO: Open filter settings in the beginning rather than loading pages directly?
          Pagination.reloadSearch(Util.getRcUrl());

          Logger.logMsg({
            msg: mw.msg("orcLogStart"),
            notify: false
          });
        }).fail((jqXHR, textStatus) => Logger.logError({
          data: textStatus,
          msg: jqXHR
        }));
      }

      /**
       * Load property names (only for Wikidata)
       */
      static loadPropertyLabels() {
        if (orcMw.isWikidata) {
          orcObj.api.get(new ApiDataLabel({ titles: orcProperties }), { async: false }).done(data => {
            if (! data.hasOwnProperty("error") && data.entities !== undefined) {
              Util.fillLabelCache(data);
            } else {
              Logger.logError({ data: data });
            }
          }).fail((jqXHR, textStatus) => Logger.logError({
            data: textStatus,
            msg: jqXHR
          }));
        }
      }

      /**
       * Get the rights of the logged-in user
       */
      static loadUserRights() {
        orcObj.api.get(new ApiDataUser({ user: orcMw.userName }), { async: false }).done(data => {
          if (! data.hasOwnProperty("error") && data.query !== undefined && data.query.users !== undefined && data.query.users.length === 1) {
            for (const right of data.query.users[0].rights) {
              orcData.userRights.add(right);
            }

            // Remove RC table columns for which the user doesn't have the required rights
            if (! orcData.userRights.has("patrol")) {
              orcConst.colsRcTable--;
            }

            if (! orcData.userRights.has("rollback")) {
              orcConst.colsRcTable--;
            }
          } else {
            Logger.logError({ data: data });
          }
        }).fail((jqXHR, textStatus) => Logger.logError({
          data: textStatus,
          msg: jqXHR
        }));
      }

      /**
       * Search filters dialog
       * @param {Object} options
       * @param {Object} options.$rcfilters jQuery object with MediaWiki's Recent Changes Filter component
       * @param {Object} options.$rcoverlay jQuery object with the overlay for MediaWiki's Recent Changes Filter component
       */
      static initDialogFilters({ $rcfilters, $rcoverlay }) {
        // TODO: Should be possible to append rcoverlay to body and open FiltersDialog in large instead of full window

        OO.inheritClass(FiltersDialog, OO.ui.ProcessDialog);

        FiltersDialog.static.name = "FiltersDialog";
        FiltersDialog.static.title = mw.msg("orcBtnSettingsFilterNoBreak");
        FiltersDialog.static.actions = [{
          action: "apply",
          flags: "primary",
          label: mw.msg("orcBtnApply")
        }, {
          flags: "safe",
          label: mw.msg("orcBtnCancel")
        }];

        FiltersDialog.static.initialized = false;

        /**
         * Initialize window contents
         */
        FiltersDialog.prototype.initialize = function() {
          if (FiltersDialog.static.initialized) {
            // Initialize only once, not needed in second window manager
            return;
          }

          FiltersDialog.static.initialized = true;
          FiltersDialog.parent.prototype.initialize.apply(this, arguments);

          this.content = new OO.ui.PanelLayout({
            expanded: false,
            padded: true
          });

          this.content.$element.append($rcoverlay);
          this.content.$element.append($rcfilters);
          this.$body.append(this.content.$element);
        };

        /**
         * Get a process for taking action
         * @param {string} action Symbolic name of action
         * @return {OO.ui.Process} Action process
         */
        FiltersDialog.prototype.getActionProcess = function(action) {
          const dialog = this;

          if (action === "apply") {
            Pagination.reloadSearch(Util.getRcUrl());
          }

          if (action) {
            return new OO.ui.Process((() => dialog.close({ action: action })));
          }

          return FiltersDialog.parent.prototype.getActionProcess.call(this, action);
        };
      }

      /**
       * Help/Info dialog
       */
      static initDialogHelp() {
        OO.inheritClass(HelpDialog, OO.ui.ProcessDialog);

        HelpDialog.static.name = "HelpDialog";
        HelpDialog.static.title = mw.msg("orcBtnHelpNoBreak");
        HelpDialog.static.actions = [{
          flags: "primary",
          label: mw.msg("orcBtnClose")
        }];

        /**
         * Initialize window contents
         */
        HelpDialog.prototype.initialize = function() {
          HelpDialog.parent.prototype.initialize.apply(this, arguments);

          this.content = new OO.ui.PanelLayout({
            expanded: false,
            padded: true
          });

          const $div = $("<div></div>", { class: cls.help });

          $div.append(`<h3>${orcConst.toolName}</h3>`);
          $div.append(`<p>${orcConst.toolNameSub}</p>`);
          $div.append(`<p class="${cls.helpContact}"><ul><li><a href="https://meta.wikimedia.org/wiki/User:YMS/ORC">${mw.msg("orcHelpContact")}</a></li>
                      <li><a href="https://meta.wikimedia.org/wiki/User:YMS/orc.js">${mw.msg("orcHelpVersions")}</a></li></ul></p>`);

          const orqueLink = `<a href="https://commons.wikimedia.org/wiki/File:Orque-Terre_du_Milieu.jpg">Orque-Terre du Milieu</a>`;
          const ccLink = `<a href="http://creativecommons.org/licenses/by-sa/3.0">CC BY-SA 3.0</a>`;
          const credits = `<p class="${cls.helpImageCredit}">${mw.msg("orcHelpImageCredits", orqueLink, ccLink)}</a>`;
          $div.append(credits);

          this.$body.html($div);
        };
      }

      /**
       * Skip Time dialog
       */
      static initDialogSkip() {
        OO.inheritClass(SkipDialog, OO.ui.ProcessDialog);

        SkipDialog.static.name = "SkipDialog";
        SkipDialog.static.title = mw.msg("orcBtnSkipNoBreak");
        SkipDialog.static.actions = [{
          flags: "safe",
          label: mw.msg("orcBtnClose")
        }, {
          action: "skip",
          flags: "primary",
          label: mw.msg("orcLabelSkipButton")
        }];

        /**
         * Initialize window contents
         */
        SkipDialog.prototype.initialize = function() {
          SkipDialog.parent.prototype.initialize.apply(this, arguments);

          const dialog = this;
          const fieldset = new OO.ui.FieldsetLayout();

          this.content = new OO.ui.PanelLayout({
            expanded: false,
            padded: true
          });

          this.inputAmount = new OO.ui.NumberInputWidget({
            input: { value: 8 },
            isInteger: true,
            max: orcConst.rcDays * orcConst.timeHrsPerDay,
            min: 1
          });

          this.inputUnit = new OO.ui.DropdownInputWidget({
            options: [{
              data: "m",
              label: mw.msg("orcLabelSkipUnitMinutes")
            }, {
              data: "h",
              label: mw.msg("orcLabelSkipUnitHours"),
              selected: true
            }, {
              data: "d",
              label: mw.msg("orcLabelSkipUnitDays")
            }],
            value: "h"
          });

          this.inputUnit.connect(this, { change: [ "validate", dialog ]});
          this.inputAmount.connect(this, { change: [ "validate", dialog ]});

          // TODO: Extend with date/time field (optional)

          fieldset.addItems([
            new OO.ui.FieldLayout(this.inputAmount, {
              align: "right",
              label: mw.msg("orcLabelSkipAmount")
            }),
            new OO.ui.FieldLayout(this.inputUnit, {
              align: "right",
              label: mw.msg("orcLabelSkipUnit")
            })
          ]);

          this.$body.append(fieldset.$element);
        };

        SkipDialog.prototype.getBodyHeight = function() {
          return 300;
          // TODO: Using an overlay would be better, but the DropdownInputWidget doesn't support it (and the DropdownWidget doesn't support "change")
        };

        SkipDialog.prototype.validate = function(dialog) {
          const amount = dialog.inputAmount.getNumericValue();
          const unit = dialog.inputUnit.getValue();

          if (amount <= 0) {
            return mw.msg("orcErrorInputValue");
          } else if (unit === "d" && amount > orcConst.rcDays) {
            // TODO: Improve validation: Any input resulting in jumping to a point in time beyond rcDays should be disallowed
            return mw.msg("orcErrorTooManyDays", orcConst.rcDays);
          } else {
            // As a non-recoverable error might have disabled the "Skip" action, re-enable it now
            for (const action of dialog.actions.list) {
              action.setDisabled(false);
            }

            return null;
          }
        };

        /**
         * Get a process for taking action
         * @param {string} action Symbolic name of action
         * @return {OO.ui.Process} Action process
         */
        SkipDialog.prototype.getActionProcess = function(action) {
          return SkipDialog.super.prototype.getActionProcess.call(this, action).next(function() {
            const dialog = this;

            if (action === "skip") {
              const validationResult = SkipDialog.prototype.validate(dialog);

              if (validationResult === null) {
                const amount = dialog.inputAmount.getNumericValue();
                const unit = dialog.inputUnit.getValue();

                dialog.close();

                Pagination.skip({
                  amount: amount,
                  unit: unit
                });
              } else {
                return new OO.ui.Error(validationResult, { recoverable: false });
              }
            }

            if (action) {
              return new OO.ui.Process((() => dialog.close({ action: action })));
            }

            return SkipDialog.parent.prototype.getActionProcess.call(this, action);
          }, this);
        };
      }

      /**
       * User actions dialog
       */
      static initDialogUserActions() {
        OO.inheritClass(UserActionsDialog, OO.ui.ProcessDialog);

        UserActionsDialog.static.name = "UserActionsDialog";
        UserActionsDialog.static.title = mw.msg("orcBtnUserActionsUser", "none");
        UserActionsDialog.static.actions = [{
          action: "close",
          flags: "primary",
          label: mw.msg("orcBtnClose"),
          modes: "contribs,default"
        }, {
          action: "patrolAll",
          icon: "check",
          label: mw.msg("orcBtnPatrolAll"),
          modes: "contribs",
          title: mw.msg("orcBtnPatrolAllInfo")
        }, {
          action: "revertAll",
          icon: "clear",
          label: mw.msg("orcBtnRevertAll"),
          modes: "contribs",
          title: mw.msg("orcBtnRevertAllInfo")
        }, {
          action: "reload",
          icon: "search",
          label: mw.msg("orcBtnReload"),
          modes: "contribs",
          title: mw.msg("orcBtnReloadInfo")
        }];

        /**
         * Get the height of the window body
         * @return {number} Height of the window body in pixels
         */
        UserActionsDialog.prototype.getBodyHeight = function() {
          // TODO: Why is this necessary? (also for other dialogs)
          return 250;
        };

        /**
         * Initialize window contents
         */
        UserActionsDialog.prototype.initialize = function() {
          UserActionsDialog.parent.prototype.initialize.apply(this, arguments);

          this.bookletLayout = new OO.ui.BookletLayout({ outlined: true });
          this.bookletLayout.addPages([
            new UserActionsDialogPageLinks("links"),
            new UserActionsDialogPageContribs("unpatrolledcontribs"),
            new UserActionsDialogPageActions("actions")
          ]);

          this.bookletLayout.connect(this, { set: "onBookletLayoutSet" });

          // TODO: Add a section for all user edits, not only unpatrolled? (use list=usercontribs instead, but then again without patrolling etc. information)
          // TODO: user information? (non-empty "anon" property is already there, could load roles, botflag, edit count, block log/count, etc.; for IPs a whois) [API: action=query&list=users])...
          // TODO: user @ ORC information? Put in user actions dialog a user info section, counting not only how many edits on how many pages have been displayed, but also how many of those have been reverted or patrolled

          this.$body.append(this.bookletLayout.$element);
        };

        /**
         * Handle the page selection
         * @param {OO.ui.PageLayout} page page that is opened now
         */
        UserActionsDialog.prototype.onBookletLayoutSet = function(page) {
          if (page.getName() === "unpatrolledcontribs" && page.getData() !== undefined) {
            this.setSize("full");
            this.actions.setMode("contribs");
            Ajax.loadUserContribs(page.getData());
            Pagination.clearFuturePageIds();
          } else {
            this.setSize("large");
            this.actions.setMode("default");
          }
        };

        /**
         * Get a process for taking action
         * @param {string} action Symbolic name of action
         * @return {OO.ui.Process} Action process
         */
        UserActionsDialog.prototype.getActionProcess = function(action) {
          if (action === "close") {
            return new OO.ui.Process(function() {
              this.close({ action: action });
            }, this);
          } else if (action === "patrolAll") {
            if (orcConfig.confirmPatrolAll) {
              OO.ui.confirm(mw.msg("orcBtnPatrolAllConfirmUser")).done(confirmed => {
                if (confirmed) {
                  Actions.patrolAllUser();
                }
              });
            } else {
              Actions.patrolAllUser();
            }
          } else if (action === "revertAll") {
            if (orcConfig.confirmRevertAll) {
              UiFactory.prompt(mw.msg("orcBtnRevertAllConfirm"), {
                size: "large",
                textInput: UiFactory.getEditSummaryPromptOptions(),
                title: mw.msg("orcBtnRevertAllConfirmTitle", this.getData())
              }).done(result => {
                if (result !== null) {
                  Actions.revertAllUser(result);
                }
              });
            } else {
              Actions.revertAllUser();
            }
          } else if (action === "reload") {
            Ajax.loadUserContribs(this.getData());
          }

          return UserActionsDialog.parent.prototype.getActionProcess.call(this, action);
        };

        /**
         * Get the OOjs UI setup process
         * @param {Object} data window opening data
         */
        UserActionsDialog.prototype.getSetupProcess = function(data) {
          return UserActionsDialog.super.prototype.getSetupProcess.call(this, data).next(function() {
            this.actions.setMode("default");
          }, this);
        };

        /**
         * Opens the dialog with a new user set
         * @param {string} user name of the user for which the dialog should be opened
         */
        UserActionsDialog.prototype.setContext = function(user) {
          UserActionsDialog.static.title = mw.msg("orcBtnUserActionsUser", user);
          this.setData(user);

          for (const page in this.bookletLayout.pages) {
            this.bookletLayout.pages[page].setUser(user);
          }

          // Prevent remembering the last opened booklet page (especially as loading contributions takes some time)
          this.bookletLayout.setPage("links");
        };
      }

      /**
       * Page actions dialog
       */
      static initDialogPageActions() {
        OO.inheritClass(PageActionsDialog, OO.ui.ProcessDialog);

        PageActionsDialog.static.name = "PageActionsDialog";
        PageActionsDialog.static.title = mw.msg("orcBtnPageActionsNoBreak", "none");
        PageActionsDialog.static.actions = [{
          flags: "primary",
          label: mw.msg("orcBtnClose")
        }];

        /**
         * Get the height of the window body
         * @return {number} Height of the window body in pixels
         */
        PageActionsDialog.prototype.getBodyHeight = function() {
          return 250;
        };

        /**
         * Initialize window contents
         */
        PageActionsDialog.prototype.initialize = function() {
          PageActionsDialog.parent.prototype.initialize.apply(this, arguments);

          this.bookletLayout = new OO.ui.BookletLayout({ outlined: true });
          this.bookletLayout.addPages([
            new PageActionsDialogPageRevisions("revs"),
            new PageActionsDialogPageFunctions("funcs"),
            new PageActionsDialogPageLinks("links")
          ]);

          this.$body.append(this.bookletLayout.$element);
        };

        /**
         * Get a process for taking action
         * @param {string} action Symbolic name of action
         * @return {OO.ui.Process} Action process
         */
        PageActionsDialog.prototype.getActionProcess = function(action) {
          if (action) {
            return new OO.ui.Process(function() {
              this.close({ action: action });
            }, this);
          }

          return PageActionsDialog.parent.prototype.getActionProcess.call(this, action);
        };

        /**
         * Opens the dialog with a new page set
         * @param {Page} page page
         */
        PageActionsDialog.prototype.setContext = function(page) {
          PageActionsDialog.static.title = mw.msg("orcBtnPageActionsNoBreak", page.title);

          for (const booklet in this.bookletLayout.pages) {
            this.bookletLayout.pages[booklet].setPage(page);
          }

          // Prevent remembering the last opened booklet page (especially for consistency with the user actions dialog)
          this.bookletLayout.setPage("revs");
        };
      }

      /**
       * (More) revision actions dialog
       */
      static initDialogRevisionActions() {
        OO.inheritClass(RevisionActionsDialog, OO.ui.ProcessDialog);

        RevisionActionsDialog.static.name = "RevisionActionsDialog";
        RevisionActionsDialog.static.title = mw.msg("orcBtnRevisionActionsNoBreak");
        RevisionActionsDialog.static.actions = [{
          flags: "primary",
          label: mw.msg("orcBtnClose")
        }];

        /**
         * Get the height of the window body
         * @return {number} Height of the window body in pixels
         */
        RevisionActionsDialog.prototype.getBodyHeight = function() {
          return 250;
        };

        /**
         * Initialize window contents
         */
        RevisionActionsDialog.prototype.initialize = function() {
          RevisionActionsDialog.parent.prototype.initialize.apply(this, arguments);

          this.bookletLayout = new OO.ui.BookletLayout({ outlined: true });
          this.bookletLayout.addPages([
            new RevisionActionsDialogPageActions("actions")
          ]);
          // TODO: Split up somehow to multiple pages? -> Undo/Other
          // TODO: include user actions and page actions additionally to the revision actions dialog? then possibly the user actions and page actions buttons could be removed, or they could be kept as easy access

          this.$body.append(this.bookletLayout.$element);
        };

        /**
         * Get a process for taking action
         * @param {string} action Symbolic name of action
         * @return {OO.ui.Process} Action process
         */
        RevisionActionsDialog.prototype.getActionProcess = function(action) {
          if (action) {
            return new OO.ui.Process(function() {
              this.close({ action: action });
            }, this);
          }

          return RevisionActionsDialog.parent.prototype.getActionProcess.call(this, action);
        };

        /**
         * Opens the dialog with a new revision set
         * @param {Revision} revision revision
         */
        RevisionActionsDialog.prototype.setContext = function(revision) {
          for (const page in this.bookletLayout.pages) {
            this.bookletLayout.pages[page].setRevision(revision);
          }

          // Prevent remembering the last opened booklet page (especially for consistency with the user actions dialog)
          this.bookletLayout.setPage("actions");
        };
      }

      /**
       * (ORC) settings dialog
       */
      static initDialogSettings() {
        OO.inheritClass(SettingsDialog, OO.ui.ProcessDialog);

        SettingsDialog.static.name = "SettingsDialog";
        SettingsDialog.static.title = mw.msg("orcBtnSettingsOrcNoBreak");
        SettingsDialog.static.actions = [{
          flags: "primary",
          label: mw.msg("orcBtnClose")
        }];

        /**
         * Get the height of the window body
         * @return {number} Height of the window body in pixels
         */
        SettingsDialog.prototype.getBodyHeight = function() {
          return 250;
        };

        /**
         * Initialize window contents
         */
        SettingsDialog.prototype.initialize = function() {
          SettingsDialog.parent.prototype.initialize.apply(this, arguments);

          this.bookletLayout = new OO.ui.BookletLayout({ outlined: true });
          this.bookletLayout.addPages([
            new SettingsDialogPageLog("log"),
            new SettingsDialogPageEdit("edit"),
            new SettingsDialogPageDisplay("display")
          ]);

          // TODO: Debug button: print a json string of the whole orcData object to the log

          this.$body.append(this.bookletLayout.$element);
        };

        /**
         * Get a process for taking action
         * @param {string} action Symbolic name of action
         * @return {OO.ui.Process} Action process
         */
        SettingsDialog.prototype.getActionProcess = function(action) {
          if (action) {
            return new OO.ui.Process(function() {
              this.close({ action: action });
            }, this);
          }

          return SettingsDialog.parent.prototype.getActionProcess.call(this, action);
        };

        // Register dialogs
        for (const manager of [ orcObj.windowManagerFirstLevel, orcObj.windowManagerSecondLevel ]) {
          manager.addWindows({
            diaFilters: new FiltersDialog({ size: "full" }),
            diaHelp: new HelpDialog({ size: "large" }),
            diaPage: new PageActionsDialog({ size: "large" }),
            diaRevision: new RevisionActionsDialog({ size: "large" }),
            diaSettings: new SettingsDialog({ size: "large" }),
            diaSkip: new SkipDialog({ size: "large" }),
            diaUser: new UserActionsDialog({ size: "large" })
          });
        }
      }

      /**
       * Build the HTML structure for the tool page
       */
      static initToolPreparePage() {
        mw.util.addCSS(orcCss);

        // Title
        document.title = `${orcConst.toolName} [${orcMw.dbName}]`;
        $("h1#firstHeading").text(orcConst.toolName);

        // Open all links in new tab
        $("head").append("<base target='_blank' />");
        // TODO: Show external links in popups instead of new tabs?

        const $mainDiv = $("<div>", { id: ids.mainDiv });

        $($mainDiv).append(orcObj.windowManagerFirstLevel.$element);
        $($mainDiv).append(orcObj.windowManagerSecondLevel.$element);

        // Re-install mw.util.$content for mw.notify
        mw.util.$content = $("<div>");
        $($mainDiv).append(mw.util.$content);

        const $all = $("<div>", { class: cls.areaAll });
        $($mainDiv).append($all);

        const $log = $("<div>", { class: cls.areaLog }).append($("<table>", { class: `${cls.areaAll} ${cls.areaLog}` }).append($("<tbody>", { class: cls.logTableBody })));
        $all.append($log);

        const $but1 = $("<div>", { class: cls.buttons });
        const $but1set = $("<div>", { class: cls.buttonsSettings });

        const btnSettings = new OO.ui.ButtonWidget({
          icon: "menu",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnSettingsOrc")),
          title: mw.msg("orcBtnSettingsOrcInfo")
        });

        btnSettings.on("click", () => UiFactory.openDialog({ page: "diaSettings" }));

        const btnFilter = new OO.ui.ButtonWidget({
          icon: "menu",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnSettingsFilter")),
          title: mw.msg("orcBtnSettingsFilterInfo")
        });

        btnFilter.on("click", () => UiFactory.openDialog({ page: "diaFilters" }));

        const btnReload = new OO.ui.ButtonWidget({
          accessKey: "r",
          icon: "search",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnReloadSearch")),
          title: mw.msg("orcBtnReloadSearchInfo")
        });

        btnReload.on("click", () => Pagination.reloadSearch());

        const btnHelp = new OO.ui.ButtonWidget({
          icon: "help",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnHelp")),
          title: mw.msg("orcBtnHelpInfo")
        });

        btnHelp.on("click", () => UiFactory.openDialog({ page: "diaHelp" }));

        const btnSkip = new OO.ui.ButtonWidget({
          flags: "progressive",
          icon: "menu",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnSkip")),
          title: mw.msg("orcBtnSkipInfo")
        });

        btnSkip.on("click", () => UiFactory.openDialog({ page: "diaSkip" }));

        orcObj.btnBack = new OO.ui.ButtonWidget({
          flags: "progressive",
          icon: "previous",
          label: new OO.ui.HtmlSnippet(mw.msg("orcBtnPrev")),
          title: mw.msg("orcBtnPrevInfo")
        });

        orcObj.btnBack.on("click", () => Pagination.goBack());

        const $info = $("<div>", { class: cls.infoSection });
        const $but1nav = $("<div>", { class: cls.buttonsNav });
        const $but2set = $("<div>", { class: cls.buttonsSettings });
        const $but2nav = $("<div>", { class: cls.buttonsNav });
        $but1.append($but1set);
        $but1set.append(btnSettings.$element);
        $but1set.append(btnFilter.$element);
        $but1set.append(btnReload.$element);
        $but1set.append(btnHelp.$element);
        $but1.append($info);
        $info.append($("<span>", { class: cls.infoQuery }));
        $info.append("<br />");
        $info.append($("<span>", { class: cls.infoTime }));
        $info.append("<br />");
        $info.append($("<span>", { class: cls.infoTimeStart }).html(mw.msg("orcLabelQueryStarted", orcData.startTime)));
        $but1.append($but1nav);
        $but1nav.append(UiFactory.getButtonNext(true).$element);
        $all.append($but1);

        const $rc = $("<div>", { class: cls.areaRc });
        $all.append($rc);

        const $popupOverlay = $("<div>", { id: ids.overlayPopup });
        $($mainDiv).append($popupOverlay);

        const $loadingOverlay = $("<div>", { id: ids.overlayLoading }).text(mw.msg("orcLoading"));
        $rc.append($loadingOverlay);

        $but2set.append(UiFactory.getButtonNext(false).$element);
        $but2nav.append(orcObj.btnBack.$element);
        $but2nav.append(btnSkip.$element);
        $but2nav.append(UiFactory.getButtonNext(false).$element);
        const $but2 = $("<div>", { class: cls.buttons }).append($but2set).append($but2nav);
        $all.append($but2);

        $("body").html($mainDiv);
      }

      /**
       * Build the language fallback chain (mainly used for Wikidata labels/descriptions)
       * Don't rely on Wikidata's own approach, as there won't e.g. be any meaningful fallback for English
       */
      static reorganizeFallBackLanguages() {
        const supportedLanguages = (mw.config.get("wgULSLanguages") === undefined || mw.config.get("wgULSLanguages") === null) ? undefined : Object.keys(mw.config.get("wgULSLanguages"));

        // User's UI language and preferred language variant, project's content language
        let langArray = [ orcMw.userLang, mw.config.get("wgPreferredVariant"), mw.config.get("wgContentLanguage") ];

        // User-specified languages (Wikidata only)
        langArray = langArray.concat(mw.config.get("wbUserSpecifiedLanguages"));

        // Babel languages (not always filled; could be loaded from page then, see labelcollect2.js:loadBabel()))
        langArray = langArray.concat(mw.config.get("wgULSBabelLanguages"));

        // Language fallback chain according to MediaWiki
        langArray = langArray.concat(mw.language.getFallbackLanguageChain());

        // Accepted languages according to Universal Language Selector
        langArray = langArray.concat(mw.config.get("wgULSAcceptLanguageList"));

        // Add own list of hardcoded fallbacks (roughly selected by assumed usefulness in Wikidata)
        langArray = langArray.concat([
          "en", "en-gb", "en-ca", "de", "de-ch", "de-at", "es", "fr", "it", "pt",
          "pt-br", "nl", "pl", "nb", "vi", "da", "sv", "la"
        ]);

        // TODO: Make fallback languages configurable

        orcData.fallBackLangs = [];

        // Remove duplicates, languages or language variants not supported by Wikidata (e.g. "no" or "en-us"; should use "nb" or "en" instead)
        // TODO: This then also pushes nowiki etc. back in the sitelink list, as even in wbSiteDetails, the languageCode is "no"
        for (const lang of langArray) {
          if (! orcData.fallBackLangs.includes(lang) && (supportedLanguages === undefined || supportedLanguages.includes(lang))) {
            orcData.fallBackLangs.push(lang);
          }
        }
      }
    }

    // Alias to allow starting the tool from document.ready()
    Orc.start = InitTool.initTool;
  }

  /**
   * Startup
   * Add the portlet that activates the tool to the MediaWiki UI
   * When clicked, initialize the required libaries
   * Note this is a pre-init function that is called before libaries etc. are loaded
   */
  $(document).ready(() => {
    if ((orcMw.isRcPage || orcMw.isScriptPage) && ! $(`#${ids.rcTab}`).length) {
      const useTabs = $("#p-namespaces").length > 0;

      const portlet = mw.util.addPortletLink(useTabs ? "p-namespaces" : "p-cactions", "#", (orcMw.isScriptPage) ? `${orcConst.toolName} [test]` : orcConst.toolName, ids.rcTab, orcConst.toolNameSub);
      // TODO: Split script into portlet loading script and rest of the script, like labelcollect.js / labelcollect2.js?

      $(portlet).click(e => {
        e.preventDefault();

        // Load dependencies
        mw.loader.using([
          "dataValues", "jquery.spinner", "wikibase", "mediawiki.util", "mediawiki.api", "mediawiki.api.messages",
          "mediawiki.jqueryMsg", "oojs-ui-core", "oojs-ui-widgets", "oojs-ui-windows", "oojs-ui.styles.icons-layout", "oojs-ui.styles.icons-editing-core", "oojs-ui.styles.icons-moderation", "oojs-ui.styles.icons-interactions", "oojs-ui.styles.icons-alerts", "oojs-ui.styles.icons-movement"
        ], () => {
          // Start main script
          Orc();
          Orc.start();
        });
      });
    }
  });
}());