MediaWiki:Common.js/addin-mooc.js

来自维基学院

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的变更的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5Ctrl-R(Mac为⌘-R
  • Google Chrome:Ctrl-Shift-R(Mac为⌘-Shift-R
  • Internet Explorer或Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5
  • Opera:Ctrl-F5
/* Any JavaScript here will be loaded for all users on every page load. */
/**
 * This JS file is part of the MOOC Addin (https://en.wikiversity.org/wiki/Mooc-Module).
 * 
 * @author Sebastian Schlicht (https://en.wikiversity.org/wiki/User:Sebschlicht)
 * @author René Pickhardt (http://www.rene-pickhardt.de/)
 * 
 * The JS code makes extensive usage of jQuery and enables main UI features such as
 *   1.) sticky navigation
 *   2.) hover effects
 *   3.) modal boxes for in-place edits
 *   4.) insertion of on-page discussion
 *
 * The MediaWiki API (https://www.mediawiki.org/wiki/API:Main_page) is used to make edits/post and retrieve the MOOC index.
 * For some requests and in order to use messages the MediaWiki JS API (https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Api) is used in addition.
 * 
 * Note:
 *
 * Names were choosen not with having collision in mind.
 * Further refactoring should prefix all functions and global variables with "AddinMooc_". 
 *
 * The style of some elements depends on the application state, such as scroll bar position, and thus require the usage of JavaScript.
 * Thus some CSS directives are not included in the CSS file but get set dynamically.
 * In future this could/should be strictly separated into
 *   1.) CSS directives limited to a state class and
 *   2.) JavaScript code en-/disabling these state classes
 */
/* necessary to avoid interpretation of special character sequences such as signations */
// <nowiki>

var AddinMooc_VERSION = '0.2';

/**
 * Global configuration object to work with constants and messages.
 */
var AddinMooc_CONFIG = {
  LOADED: 0,
  MSG_PREFIX: 'AddinMooc-',
  store: {},
  get: function(key) {
    return this.store[key];
  },
  set: function(key, value) {
    this.store[key] = value;
  },
  log: function(logLevel, key, params) {
    if (arguments.length > 0) {
      var minLevel = arguments[0];
      var crrLevel = this.get('LOG_LEVEL');
      if (crrLevel != -1 && minLevel >= crrLevel) {
        var msgParams = [];
        for (var i = 1; i < arguments.length; ++i) {
          msgParams[i - 1] = arguments[i];
        }
        console.log(this.message.apply(this, msgParams));
      }
    }
  },
  message: function(key, params) {
    var msgParams = [];
    msgParams[0] = this.MSG_PREFIX + key;
    for (var i = 1; i < arguments.length; ++i) {
      msgParams[i] = arguments[i];
    }
    // call message constructor with additional function parameters in separate object
    return mw.message.apply(mw.message, msgParams).text();
  },
  setMessage: function(key, message) {
    mw.messages.set(this.MSG_PREFIX + key, message);
  }
};

/*####################
  # ENTRY POINT
  # system initialization
  ####################*/

// load config
importScript('MediaWiki:Common.js/addin-mooc-config.js');

// load messages
importScript('MediaWiki:Common.js/addin-mooc-localization.js');

//DEBUG
var execOnReady = function(callback) {
  if (AddinMooc_CONFIG.LOADED < 2) {
    setTimeout(function() {
      execOnReady(callback);
    }, 200);
  } else {
    callback();
  }
};

// declare global variables
var PARAMETER_KEY = {
  FURTHER_READING: 'furtherReading',
  LEARNING_GOALS: 'learningGoals',
  NUM_THREADS: 'numThreads',
  NUM_THREADS_OPEN: 'numThreadsOpen',
  VIDEO: 'video'
};
var AddinMooc_root;
var _base;
var _fullPath;
var _indexSection;
var _indexTitle;
var _index;
var nItemNav;
var sidebar;
var discussion;

// register onHashChange to expand sections browsed via anchors
if ("onhashchange" in window) {
  window.onhashchange = function() {
    hashChanged(window.location.hash);
  };
} else {
  var prevHash = window.location.hash;
  window.setInterval(function() {
    if (window.location.hash != prevHash) {
      prevHash = window.location.hash;
      hashChanged(prevHash);
    }
  }, 100);
}

execOnReady(function() {

// load jQuery
$(document).ready(function() {
  // setup user agent for API requests
  $.ajaxSetup({
    beforeSend: function(request) {
      request.setRequestHeader("User-Agent", AddinMooc_CONFIG.get('USER_AGENT_NAME') + '/' + AddinMooc_VERSION + ' (' + AddinMooc_CONFIG.get('USER_AGENT_URL') + '; ' + AddinMooc_CONFIG.get('USER_AGENT_EMAIL') + ')');
    }
  });
	
  // connect to UI via DOM tree
  AddinMooc_root = $('#addin-mooc');
  _base = $('#baseUrl').text();
  _fullPath = $('#path').text();
  _indexSection = $('#section').text();
  _indexTitle = $('#indexUrl').text();
  nItemNav = $('#item-navigation');
  sidebar = $('#navigation');
  
  if (AddinMooc_root.length === 0) {// not a MOOC page
    AddinMooc_CONFIG.log(0, 'LOG_PAGE_NOMOOC');
    return;
  }
  
  // initialize
  if (_fullPath === '') {// path of root item equals base
    _fullPath = _base;
  }
  AddinMooc_CONFIG.log(0, 'LOG_INDEX', _indexTitle, _base);
  _index = MoocIndex(_indexTitle, _base);
  if (_indexSection !== '') {// use current item if not root
    _index.useItem(_indexSection, _fullPath);
  }
  discussion = Discussion();
  
  // load item navigation
  nItemNav.children('.section-link-wrapper').click(function() {
    var nSectionLink = $(this);
    var nSection = $('#' + nSectionLink.attr('id').substring(13));
    if (nSection.hasClass('collapsed')) {
      expand(nSection);
    }
    scrollIntoView(nSection);
    return false;
  });
  nItemNav.toggle(true);

  // collapse script section
  collapse($('#script'));
  // expand active section
  hashChanged(window.location.hash);
  //fix section for duration of section expansion animation
  if (window.location.hash !== '') {//Q: safe to delete?
    var section = $(window.location.hash);
    fixView(section, 600);
  }
  
  /**
   * prepares headers
   * * expand/collapse section when header clicked
   * * fade in/out action buttons when entering section
   */
  $('.section > .header').each(function() {
    var nHeader = $(this);
    var nSection = nHeader.parent();
    nHeader.click(function(e) {
      var target = $(e.target);
      if (!target.is('.header', ':header') && target.parents(':header').length === 0) {// filter clicks at action buttons
        return true;
      }
      if (nSection.hasClass('collapsed')) {
        expand(nSection);
      } else {
        collapse(nSection);
      }
      return false;
    });
    var nActions = nHeader.find('.actions');
    var nActionButtons = nActions.children().not('.modal-box');
    nActionButtons.each(function() {// remove image links from action buttons
      var btn = $(this);
      var img = btn.find('img');
      btn.append(img).find('a').remove();
    });
    nSection.mouseenter(function() {
      nActionButtons.stop().fadeIn();
    });
    nSection.mouseleave(function() {
      nActionButtons.stop().fadeOut();
    });
  });
  
  /**
   * loads overlays
   * * fade in/out overlays when mouse entering/leaving parent
   */
  $('.overlay').parent().mouseenter(function() {
    var overlay = $(this).children('.overlay');
    if (overlay.css('display') === 'none') {
      overlay.stop().toggle('fast');
    }
  });
  $('.overlay').parent().mouseleave(function() {
    var overlay = $(this).children('.overlay');
    if (overlay.css('display') !== 'none') {
      overlay.stop().toggle('fast');
    }
  });
  
  /**
   * prepares child units
   * * register click event for unit
   * * register click event for discussion statistic
   * * fade in/out discussion statistic when mouse entering/leaving
   * * get video URL
   */
  var unitButtons = [];
  var videoTitles = [];
  $('.children .unit').not('#addUnit').not('#addLesson').not('#addMooc').each(function() {
    var nChild = $(this);
    var nIconBar = nChild.find('.icon-bar');
    var nIconBarItems = nIconBar.find('li').not('.disabled');
    var iconBarOpacity = nIconBarItems.css('opacity');
    var nDownloadButton = nIconBar.find('li').eq(1);
    if (nDownloadButton.length > 0 && !nDownloadButton.hasClass('disabled')) {
      unitButtons.push(nDownloadButton);
      videoTitles.push(nDownloadButton.children('a').attr('href').substring(6).replace(/_/g, ' '));
    }
    var nDisStatisticWrapper = nChild.find('.discussion-statistic-wrapper');
    var nDisStat = nDisStatisticWrapper.children('.discussion-statistic');
    var url = nChild.children('.content').children('.title').find('a').attr('href');
    nChild.mouseenter(function() {// show disussion stats when mouse enters child
      nDisStatisticWrapper.stop().fadeIn();
      nIconBarItems.css('opacity', '1');
    });
    nChild.mouseleave(function() {// hide discussion stats when mouse leaves child
      nDisStatisticWrapper.stop().fadeOut();
      nIconBarItems.css('opacity', iconBarOpacity);
    });
    nChild.click(function() {// item click (may target underlying elements)
      window.location = url;
      return true;
    });
    nDisStat.click(function() {// discussion statistic click
      window.location = url + '#discussion';
      return false;
    });
	});
  getVideoUrls(videoTitles, function(videoUrls) {
		for (var i = 0; i < videoTitles.length; ++i) {
			var url = videoUrls[videoTitles[i]];
			if (url) {
				unitButtons[i].children('a').attr('href', url);
			}
		}
	});
  
  // make edit text links working in empty sections
  $('.empty-section .edit-text').click(function() {
    var section = $(this).parents('.section');
    if (section.length == 1) {
      section.children('.header').find('.btn-edit').click();
    }
  });
  
  /**
   * makes item navigation sticky at upper screen border (#1)
   */
  if (nItemNav.length > 0) {
    var itemNavTop = nItemNav.offset().top;//TODO ensure offset().top work correctly
    $(window).scroll(function() {
      var y = $(window).scrollTop();
      var isFixed = nItemNav.hasClass('fixed');
      if (y >= itemNavTop) {
        if (!isFixed) {
          nItemNav.after($('<div>', {
            'id': 'qn-replace',
            'height': nItemNav.height()
          }));
          nItemNav.css('width', nItemNav.outerWidth());
          nItemNav.css('position', 'fixed');
          nItemNav.css('top', 0);
          nItemNav.addClass('fixed');
        }
      } else {
        if (isFixed) {
          nItemNav.css('width', '100%');
          nItemNav.css('position', 'relative');
          nItemNav.css('top', null);
          nItemNav.removeClass('fixed');
          nItemNav.next().remove();
        }
      }
    });
  }
  
  /**
   * makes navigation bar sticky at upper right screen border
   */
  if (sidebar.length > 0) {
    var header = sidebar.find('.header-wrapper');
    var sidebarTop = sidebar.offset().top;
    var marginBottom = 10;
    function fixNavBarHeader(header) {
      header.css('width', header.outerWidth());
      header.css('position', 'fixed');
      header.addClass('fixed');
    }
    function resetNavBarHeader(header) {
      header.removeClass('fixed');
      header.css('position', 'absolute');
      header.css('width', '100%');
    }
    function fixNavBar(navBar) {
      navBar.removeClass('trailing');
      navBar.css('bottom', 'auto');
      navBar.css('position', 'fixed');
      navBar.css('top', 0);
      navBar.addClass('fixed');
    }
    function preventNavBarScrolling(navBar, marginBottom) {
      navBar.removeClass('fixed');
      navBar.css('top', 'auto');
      navBar.css('position', 'fixed');
      navBar.css('bottom', marginBottom);
      navBar.addClass('trailing');
    }
    function resetNavBar(navBar) {
      navBar.removeClass('fixed');
      navBar.removeClass('trailing');
      navBar.css('position', 'relative');
    }
    $(window).scroll(function() {
      var maxY = sidebarTop + sidebar.outerHeight();
      var h = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
      var y = $(this).scrollTop(); 
      var navBarScrolling = !sidebar.hasClass('trailing');
      var navBarFixed = sidebar.hasClass('fixed');
      var headerFixed = header.hasClass('fixed');
      if (y >= sidebarTop) {// navigation bar reached top screen border
        if (sidebar.outerHeight() <= h - marginBottom) {// fix navigation bar that fits in window
          if (!navBarFixed) {
            fixNavBar(sidebar);
          }
        } else {// navigation bar too large
          if (!headerFixed) { // fix navigation header
            fixNavBarHeader(header);
          }
          if (y + h >= maxY + marginBottom) {// disable scrolling when navigation bottom reached
            if (navBarScrolling) {
              preventNavBarScrolling(sidebar, marginBottom);
            }
          } else {// enable scrolling if still content available
            if (!navBarScrolling) {
              resetNavBar(sidebar);
            }
          }
        }
      } else {// navigation bar is back at its place
        if (headerFixed) {
          resetNavBarHeader(header);
        }
        if (navBarFixed) {
          resetNavBar(sidebar);
        }
      }
    });
  }
  
  /**
   * makes section headers sticky at upper screen border (#2)
   */
  if (AddinMooc_root.length > 0) {
    function setActiveSection(section) {
      var activeSection = $('.section').filter('.active');
      if (activeSection.length > 0) {
        setSectionActive(activeSection, false);
      }
      if (section != null) {
        setSectionActive(section, true);
      } else {//TODO replace with cross browser compatible solution (problems in e.g. Chrome 36.0.1985.125)
        //history.replaceState(null, null, window.location.pathname);
      }
    }
    function setSectionActive(section, isActive) {
      var sectionId = section.attr('id');
      var sectionAnchor = nItemNav.find('#section-link-' + sectionId);
      if (isActive) {//TODO replace with cross browser compatible solution (problems in e.g. Chrome 36.0.1985.125)
        sectionAnchor.addClass('active');
        section.addClass('active');
        //history.replaceState({}, '', '#' + sectionId);
      } else {
        sectionAnchor.removeClass('active');
        section.removeClass('active');
        resetHeader(section.children('.header'));
      }
    }
    function fixHeader(header, top) {
      header.css('position', 'fixed');
      header.css('top', top);
      header.css('width', header.parent().width());
      header.removeClass('trailing');
      header.addClass('fixed');
    }
    function resetHeader(header) {
      if (header.hasClass('fixed')) {
        header.css('position', 'absolute');
        header.css('width', '100%');
        header.removeClass('fixed');
      }
      header.css('top', 0);
      header.removeClass('trailing');
    }
    function trailHeader(header) {
      if (header.hasClass('fixed')) {
        header.css('position', 'absolute');
        header.css('width', '100%');
        header.removeClass('fixed');
      }
      header.css('top', header.parent().height() - header.outerHeight());
      header.addClass('trailing');
    }
    $(window).scroll(function() {
      var y = $(window).scrollTop();
      var h = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
      var marginTop = 0;
      if (nItemNav.hasClass('fixed')) {// correct scroll position
        marginTop = nItemNav.outerHeight() - 1;
        y += marginTop;
      }
      var activeSection = null;
      $('.section').each(function() {
        var section = $(this);
        var sectionHeader = section.children('.header');
        var sectionTop = section.offset().top;
        var sectionHeight = section.height();
        var isActive = section.hasClass('active');
        var isFixed = sectionHeader.hasClass('fixed');

        if (y >= sectionTop && y <= sectionTop + sectionHeight) {// active section
          if (!isActive) {
            setActiveSection(section);
          }
          activeSection = section;
          if (y <= sectionTop + sectionHeight - sectionHeader.outerHeight()) {// header can be fixed
            if (!isFixed) {
              fixHeader(sectionHeader, marginTop);
            }
          } else {// header reached section bottom
            if (!sectionHeader.hasClass('trailing')) {
              trailHeader(sectionHeader);
            }
          }
        } else {
          if (isActive) {
            resetHeader(sectionHeader);
          }
        }
      });
      if (activeSection == null) {
        setActiveSection(null);
      }
    });
  }
  
  // inject modal boxes
  prepareModalBoxes();
  // fill modal boxes when item loaded
  _index.retrieveItem(function(item) {
    fillModalBoxes(item);
  });
  
  // make edit buttons clickable
  var showModalBox = function() {
    var btn = $(this);
    var modal = btn.next('.modal-box');
    if (modal.length == 0) {
      modal = btn.next().next('.modal-box');
    }
    //TODO what happens if no header but addLesson aso?
    var header = modal.parent().parent();
    nItemNav.css('z-index', 1);
    header.css('z-index', 2);
    // show modal box with focus on edit field
    var editField = modal.find('fieldset').children('textarea');
    modal.toggle('fast', function() {
      editField.focus();
    });
    return false;
  };
  $('.btn-edit').each(function() {
    var btn = $(this);
    btn.click(showModalBox);
  });
  
  // make add unit div clickable
  var divAddUnit = $('#addUnit');
  var imgAddUnit = divAddUnit.find('img');
  divAddUnit.find('span').append(imgAddUnit).children('a.image').remove();
  divAddUnit.click(showModalBox);
  divAddUnit.show();
  // make add lesson clickable
  var divAddLesson = $('#addLesson');
  var imgAddLesson = divAddLesson.find('img');
  divAddLesson.find('span').append(imgAddLesson).children('a.image').remove();
  divAddLesson.click(showModalBox);
  divAddLesson.show();
  // make add MOOC clickable
  var divAddMooc = $('#addMooc');
  var imgAddMooc = divAddMooc.find('img');
  divAddMooc.find('span').append(imgAddMooc).children('a').remove();
  divAddMooc.click(showModalBox);
  
  // let redlinks create invoke pages
  var invokeItem = Item(Header(0, null, null, null), _index);
  $('#navigation a.new').click(function() {
    var link = $(this);
    var itemUrl = link.attr('href').replace(/_/g, ' ');
    itemUrl = itemUrl.substring(0, itemUrl.length - 22);
    var itemTitle = itemUrl.substring(19);
    createPage(itemTitle, invokeItem.getInvokeCode(), 'invoke page for MOOC item created', function() {// browse to created page
      window.location.href = itemUrl;
    });
    return false;
  });
  
  // inject discussion UI if not MOOC root
  if (_fullPath != _base) {
    var talkPageTitle = getTalkPageTitle(_fullPath);
    var scriptTalkPage = talkPageTitle + '/script';
    var quizTalkPage = talkPageTitle + '/quiz';
    renderThreads([
      talkPageTitle, scriptTalkPage, quizTalkPage
    ]);
  }
});

//DEBUG END
});

/*####################
  # UI UTILITIES
  # helper functions to change the user interface
  ####################*/

/**
 * Handler for onHashChange event. Expands the section browsed to via anchor.
 * @param {String} anchor ("hash") value
 */
function hashChanged(hash) {
  if (hash.length > 0) {
    var section = $(hash);
    if (section.hasClass('collapsed')) {
      expand(section);
    }
  }
}

/**
 * Reloads the current page.
 * @param {String} (optional) page anchor to be set
 */
function reloadPage(anchor) {
  if (typeof anchor === 'undefined') {
    document.location.search = document.location.search + '&action=purge';
  } else {
    window.location.href = document.URL.replace(/#.*$/, '') + '?action=purge' + anchor;
  }
}
 
/**
 * Displays a notification message to the user.
 * Uses mw.Message to generate messages.
 * @param {String} message key
 * @param {Array} message parameters
 */
function notifyUser(msgKey, msgParams) {/* Q: do we really need this? */
	var msgValue = mw.msg(msgKey, msgParams);
	alert(msgValue);
}


/**
 * Collapses a section to a fixed height that can be configured. The section is then expandable again.
 * Only applies to non-collapsed sections that are larger than the collapsed UI would be.
 * @param {jQuery} section node to be collapsed
 */
function collapse(section) {//TODO: rename to collapseSection; use config height to check if to collapse
  var content = section.children('.content');
  if (section.hasClass('collapsed') || content.height() <= 80) {
    return;
  }
  section.addClass('collapsed');
  var btnReadMore = $('<div>', {
    'class': 'btn-expand'
  }).html(AddinMooc_CONFIG.message('UI_SECTION_BTN_EXPAND'));
  btnReadMore.click(function() {
    expand(section);
    return false;
  });// expandable via button click
  section.append(btnReadMore);
  section.on('click', function() {
    expand(section);
    return true;
  });// expandable via section click (may target underlying elements)
  section.focusin(function() {
    expand(section);
    return true;
  });// expandable via focusing any child element (may target underlying elements)
  var btnHeight = btnReadMore.css('height');
  btnReadMore.css('height', '0');
  btnReadMore.stop().animate({
    'height': btnHeight
  }, function() {
    btnReadMore.css('height', null);
  });
  content.stop().animate({
    'height': AddinMooc_CONFIG.get('UI_SECTION_COLLAPSED_HEIGHT') + 'px'
  }, 'slow');
}

/**
 * Expands a section to its full height making it collapsible again.
 * @param {jQuery} section node to be expanded
 */
function expand(section) {//TODO: rename to expandSection
  section.removeClass('collapsed');
  var content = section.children('.content');
  var crrHeight = content.css('height');
  var targetHeight = content.css('height', 'auto').height();
  section.children('.btn-expand').stop().animate({
    'height': '0'
  }, 'slow', function() {
    $(this).remove();
  });
  section.off('click');
  content.css('height', crrHeight);
  content.stop().animate({
    'height': targetHeight
  }, 'slow', function() {
    content.css('height', 'auto');
  });
}

/**
 * Collapses a thread to match a fixed number of characters that can be configured. The thread is then expandable again.
 * @param {jQuery} thread node to be collapsed
 * @param {Object} thread displayed by the node
 */
function collapseThread(nThread, thread) {
  var content = nThread.children('.content');
  var nMessage = content.children('.message');
  var nMessageText = nMessage.children('.text');
  var nReplies = content.children('.replies');
  if (nThread.hasClass('collapsed') || (nMessageText.text().length < 100 && nReplies.children().length < 2 && content.height() <= 120)) {
    return;
  }
  var collapsedText = cutThreadContent(thread, AddinMooc_CONFIG.get('UI_THREAD_COLLAPSED_NUMCHARACTERS'));
  nMessageText.html(collapsedText);
  var targetHeight = nMessage.outerHeight() + 5;
  nMessageText.html(thread.htmlContent);
  nThread.addClass('collapsed');
  var btnReadMore = $('<div>', {
    'class': 'btn-expand'
  }).html(AddinMooc_CONFIG.message('UI_THREAD_BTN_EXPAND'));
  btnReadMore.click(function() {
    expandThread(nThread, thread);
    return false;
  });// expandable via button click
  nThread.append(btnReadMore);
  nThread.on('click', function() {
    expandThread(nThread, thread);
    return true;
  });// expandable via thread click (may target underlying elements)
  nThread.focusin(function() {
    expandThread(nThread, thread);
    return true;
  });// expandable via focusing any child element (may target underlying elements)
  var btnHeight = btnReadMore.css('height');
  btnReadMore.css('height', '0');
  btnReadMore.stop().animate({
    'height': btnHeight
  }, function() {
    btnReadMore.css('height', null);
  });
  content.stop().animate({
    'height': targetHeight + 'px'
  }, 'slow', function() {
    nMessageText.html(collapsedText);
  });
}

/**
 * Expands a thread to its full height making it collapsible again.
 * @param {jQuery} thread node to be expanded
 * @param {Object} thread displayed by the node
 */
function expandThread(nThread, thread) {
  nThread.removeClass('collapsed');
  var content = nThread.children('.content');
  var nMessageText = content.children('.message').children('.text');
  nMessageText.html(thread.htmlContent);
  var crrHeight = content.css('height');
  var targetHeight = content.css('height', 'auto').height();
  nThread.children('.btn-expand').stop().animate({
    'height': '0'
  }, 'slow', function() {
    $(this).remove();
  });
  nThread.off('click');
  content.css('height', crrHeight);
  content.stop().animate({
    'height': targetHeight
  }, 'slow', function() {
    content.css('height', 'auto');
  });
}

/**
 * Fixes a view at the upper screen border via scroll lock.
 * @param {jQuery} element to be fixed
 * @param {int} duration of the scroll lock
 */
function fixView(element, duration) {//Q: do we need this anymore?
  if (duration > 0) {
    element.css('background-color', '#FFF');
    var width = element.width();
    element.css('position', 'fixed');
    element.css('width', width);
    element.css('top', '0');
    var zIndex = element.css('z-index');
    element.css('z-index', 100);
    setTimeout(function() {
      element.css('position', 'relative');
      element.css('top', null);
      element.css('z-index', zIndex);
      window.scroll(0, element.offset().top);
      $(window).scroll();// fire jQuery event
    }, duration);
  }
}

/**
 * Scrolls an element into the user's view.
 * The final scroll position can handle movement of the element, the animation can not.
 * @param {jQuery} element to scroll into view
 * @param {String} 'top'/'bottom' if the element should be aligned at the upper/lower screen border. Defaults to 'top'.
 * @param {int} (optional) duration of the scroll animation; defaults to 1000ms
 */
function scrollIntoView(element, align, duration) {
  if (typeof duration === 'undefined') {
    duration = 1000;
  }
  var Alignment = {
    'TOP': 1,
    'BOTTOM': 2
  };
  if (align === 'bottom') {
    align = Alignment.BOTTOM;
  } else {
    align = Alignment.TOP;
  }
  var targetTop;
  var adjustAnimation = function(now, fx) {
    var h = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
    var y = $(window).scrollTop();
    var crrTop = element.offset().top;
    if (align === Alignment.BOTTOM) {
      crrTop += element.height() - h;
      //TODO currently just if scrolled FAR enough
      if (crrTop + h - y < h) {// element already in view
        crrTop = y;
        fx.end = y;
        return true;
      }
    } else {
      //TODO check if already in view
    }
    if (nItemNav !== 'undefined' && nItemNav.hasClass('fixed')) {
      crrTop -= nItemNav.height();//TODO remove if workaround found
    }
    if (targetTop != crrTop) {
      targetTop = crrTop;
      fx.end = targetTop;//TODO is there a way to do this smoothly?
    }
    return false;
  };
  if (!adjustAnimation(0, {})) {
    $('html, body').stop().animate({
      scrollTop: targetTop
    }, {
      'duration': duration,
      'step': adjustAnimation
    });
  }
}

/**
 * Prepares all modal boxes. Registers all box UI events.
 */
function prepareModalBoxes() {
  // fill modal boxes to save changes on item or its resources
  prepareModalBox('learningGoals', 'edit', 5, saveChanges);
  prepareModalBox('video', 'edit', 1, saveChanges);
  prepareModalBox('script', 'edit', 5, saveChanges);
  prepareModalBox('quiz', 'edit', 5, saveChanges);
  prepareModalBox('furtherReading', 'edit', 5, saveChanges);

  // fill modal boxes to add a MOOC item
  prepareModalBox('addLesson', 'add-lesson', 1, function(idSection, value, summary) {
    addLesson(value, _index.item, summary, function() {
      reloadPage();
    });
  });
  prepareModalBox('addUnit', 'add-unit', 1, function(idSection, value, summary) {
    addUnit(value, _index.item, summary, function() {
      reloadPage();
    });
  });
  prepareModalBox('createMooc', 'create-mooc', 1, function(idSection, value, summary) {
    createMooc(value, summary, function() {
      reloadPage();
    });
  }).find('.btn-save').prop('disabled', false);

  // make boxes closable via button
  $('.modal-box').each(function() {
    var modal = $(this);
    modal.find('.btn-close').click(function() {
      closeModalBox(modal);
      return false;
    });
  });
  // make boxes closable via background click
  $('.modal-box > .background').click(function(e) {
    closeModalBox($(e.target).parent());
    return false;
  });
  // make boxes closable via ESC key
  $('.modal-box').on('keydown', function(e) { 
    if (e.which == 27) {
      closeModalBox($(this));
    }
  });
}

/**
 * Creates a modal box.
 * @param {String} identifier of the parameter/resource the modal box will be responsible for
 * @param {String} intend identifier of the box (add/edit)
 * @param {int} number of lines needed for editing
 * @param {function} onSave callback
 * @return {jQuery} node of the modal box created
 */
function prepareModalBox(idSection, intentType, numLines, finishCallback) {
  // create modal box structure
  var modalBox = $('#modal-' + intentType + '-' + idSection);
  modalBox.append($('<div>', {
    'class': 'background'
  }));
  var boxContent = $('<div>', {
    'class': 'content border-box'
  });
  boxContent.append($('<div>', {
    'class': 'btn-close'
  }));
  var editFieldset = $('<fieldset>', {
    'class': 'edit-field'
  });
  // label and textarea for value
  editFieldset.append($('<label>', {
    'for': 'edit-field-' + idSection,
    'class': 'label-title',
    'text': AddinMooc_CONFIG.message('UI_MODAL_LABEL_TITLE_' + idSection)
  }));
  var editField;
  if (numLines > 1) {
    editField = $('<textarea>', {
      'class': 'border-box',
      'id': 'edit-field-' + idSection
    });
  } else {
    editField = $('<input>', {
      'class': 'border-box',
      'id': 'edit-field-' + idSection,
      'type': 'text'
    });
  }
  editFieldset.append(editField);
  // label and input box for edit summary
  editFieldset.append($('<label>', {
    'for': 'summary-' + idSection,
    'class': 'label-summary',
    'text': AddinMooc_CONFIG.message('UI_MODAL_LABEL_SUMMARY')
  }));
  var ibSummary = $('<input>', {
    'id': 'summary-' + idSection,
    'class': 'border-box summary',
    'type': 'text'
  });
  editFieldset.append(ibSummary);
  // help text
  var divHelpText = $('<div>', {
    'class': 'help'
  }).html(AddinMooc_CONFIG.message('UI_MODAL_HELP_' + idSection, _fullPath));
  editFieldset.append(divHelpText);
  boxContent.append(editFieldset);
  //Q: why not put in edit fieldset?
  // finish button
  var btnSave = $('<input>', {
    'class': 'btn-save',
    'disabled': true,
    'type': 'button',
    'value': AddinMooc_CONFIG.message('UI_MODAL_BTN_' + intentType)
  });
  boxContent.append(btnSave);
  btnSave.click(function() {
    if (!btnSave.prop('disabled')) {
      btnSave.prop('disabled', true);
      finishCallback(idSection, editField.val(), ibSummary.val());
    }
    return false;
  });
  modalBox.append(boxContent);
  return modalBox;
}

/**
 * Fills all modal boxes.
 * @param {Object} MOOC item the boxes will enable to edit
 */
function fillModalBoxes(item) {
  // inject item data
  $('#edit-field-learningGoals').append(item.getParameter(PARAMETER_KEY.LEARNING_GOALS));
  $('#modal-edit-learningGoals').find('.btn-save').prop('disabled', false);
  $('#edit-field-video').val(item.getParameter(PARAMETER_KEY.VIDEO));
  $('#modal-edit-video').find('.btn-save').prop('disabled', false);
  $('#edit-field-furtherReading').append(item.getParameter(PARAMETER_KEY.FURTHER_READING));
  $('#modal-edit-furtherReading').find('.btn-save').prop('disabled', false);
  $('#modal-add-lesson-addLesson').find('.btn-save').prop('disabled', false);
  $('#modal-add-unit-addUnit').find('.btn-save').prop('disabled', false);

  // retrieve and inject additional resources
  var taScript = $('#edit-field-script');
  item.retrieveScript(function(scriptText) {
    taScript.text(scriptText).html();
    $('#modal-edit-script').find('.btn-save').prop('disabled', false);
  }, function(jqXHR) {
    if (jqXHR.status == 404) {// script missing
      taScript.text(AddinMooc_CONFIG.message('DEFVAL_SCRIPT', item.header.type)).html();
    }
    $('#modal-edit-script').find('.btn-save').prop('disabled', false);
  });
  var taQuiz = $('#edit-field-quiz');
  item.retrieveQuiz(function(quizText) {
    taQuiz.text(quizText).html();
    $('#modal-edit-quiz').find('.btn-save').prop('disabled', false);
  }, function(jqXHR) {
    if (jqXHR.status == 404) {// quiz missing
      taQuiz.text(AddinMooc_CONFIG.message('DEFVAL_QUIZ')).html();
      $('#modal-edit-quiz').find('.btn-save').prop('disabled', false);
    }
  });
}

/**
 * Closes a modal box.
 * @param {jQuery} modal box to be closed
 */
function closeModalBox(modal) {
  nItemNav.css('z-index', 1001);
  modal.parent().parent().css('z-index', 1);
  modal.fadeOut();
}

/**
 * Saves changes to an item or one of its resources. Updates the MOOC index.
 * @param {String} identifier of the updated parameter/resource
 * @param {String} section value
 * @param {String} edit summary appendix
 */
function saveChanges(idSection, value, summary) {//Q: isn't idSection == key?
  var sucCallback = function() {
    reloadPage('#' + idSection);
  };
  if (idSection === 'script') {// update script resource
    if (_index.item.script === null) {
      // add category
      value += '\n<noinclude>[[category:' + _index.base + '-MOOC]]</noinclude>';
    }
    updateScript(_index.item, value, summary, sucCallback);
  } else if (idSection === 'quiz') {// update quiz resource
    if (_index.item.quiz === null) {
      // add category
      value += '\n<noinclude>[[category:' + _index.base + '-MOOC]][[category:Quizzes]]</noinclude>';
    }
    updateQuiz(_index.item, value, summary, sucCallback);
  } else {// update index parameter
    var key = null;
    if (idSection === 'learningGoals') {
      key = PARAMETER_KEY.LEARNING_GOALS;
      value = value.replace(/(^|\n)\*/g, '\n#');
    } else if (idSection === 'video') {
      key =  PARAMETER_KEY.VIDEO;
      value = value.replace(/(^|\n)\*/g, '');
    } else if (idSection === 'furtherReading') {
      key = PARAMETER_KEY.FURTHER_READING;
      value = value.replace(/(^|\n)\*/g, '\n#');
    }
    if (key !== null) {
      if (summary === '') {
        summary = AddinMooc_CONFIG.message('DEFSUM_EDIT_' + idSection);
      }
      _index.item.setParameter(key, value);
      updateIndex(_index.item, summary, sucCallback);
    }
  }
}

/**
 * Injects the interface to ask a question into a section.
 * @param {String} section identifier
 * @param {String} title of the talk page a question would be placed on
 * @param {boolean} inject a button only into section header if set to true
 */
function insertAskQuestionUI(identifier, talkPageTitle, ownUi) {
  var nSection = $('#' + identifier);
  var nContent = nSection.children('.content');
  var btn = nSection.children('.header').find('.btn-ask-question');
  if (ownUi) {// create UI
    var ui = createAskQuestionUI(identifier, talkPageTitle);
    nContent.append(ui);
    btn.click(function() {
      // scroll to UI and focus title box
      scrollIntoView(ui, 'bottom');
      ui.children('.title').focus();
    });
  } else {// scroll to discussion ui
    btn.click(function() {
      var nDiscussionUi = $('#discussion').find('.ask-question');
      scrollIntoView(nDiscussionUi, 'bottom');
      nDiscussionUi.children('.title').focus();
    });
  }
}

/**
 * Creates the interface to ask a question.
 * @param {String} identifier of the parameter/resource the interface belongs to
 * @param {String} title of the talk page a question would be placed on
 * @return {jQuery} node of the interface created
 */
function createAskQuestionUI(identifier, talkPageTitle) {
  var ui = $('<div>', {
    'class': 'ask-question'
  });
  // question title
  var lbTitle = $('<label>', {
    'for': 'thread-title-' + identifier,
    'text': AddinMooc_CONFIG.message('UI_ASK_LABEL_TITLE')
  });
  ui.append(lbTitle);
  var iTitle = $('<input>', {
    'class': 'title border-box',
    'id': 'thread-title-' + identifier,
    'type': 'text'
  });
  ui.append(iTitle);
  // question content
  var lbContent = $('<label>', {
    'for': 'thread-content-' + identifier,
    'text': AddinMooc_CONFIG.message('UI_ASK_LABEL_CONTENT')
  });
  ui.append(lbContent);
  var minRows = 3;
  var teaContent = $('<textarea>', {
    'class': 'border-box',
    'id': 'thread-content-' + identifier,
    'rows': minRows
  });
  ui.append(teaContent);
  $(document).on('input.textarea', '#' + teaContent.attr('id'), function() {
    var rows = this.value.split('\n').length;
    this.rows = rows < minRows ? minRows : rows;
  });
  // ask button
  var btnAsk = $('<input>', {
    'class': 'btn-ask',
    'type': 'button',
    'value': AddinMooc_CONFIG.message('UI_ASK_BTN_ASK')
  });
  ui.append(btnAsk);
  btnAsk.click(function() {
    if (btnAsk.prop('disabled')) {
      return;
    }
    btnAsk.prop('disabled', true);
    // add section to talk page
    var title = iTitle.val();
    if (title.length > 0) {
      var content = stripPost(teaContent.val());
      content += ' --~~~~';
      asking = true;
      _index.retrieveItem(function(item) {
        item.discussion = discussion;
        addThread(item, getTalkPage(talkPageTitle), title, content, function() {
          reloadPage('#discussion');
        });
      });
    } else {
      notifyUser('q-no-title', null, {//TODO: display error on page and scroll in view
        'class': 'error'
      });
    }
  });
  var blackIn = function() {
    ui.css('opacity', 1);
  };
  var greyOut = function() {
    if (ui.children(':focus').length > 0) {// dont grey out if having focus
      return;
    }
    ui.css('opacity', 0.6);
  };
  ui.mouseleave(greyOut);
  ui.focusout(greyOut);
  greyOut();
  ui.mouseenter(blackIn);
  ui.focusin(blackIn);
  return ui;
}

/**
 * Creates the interface to reply to a post.
 * @param {Object} postData object including root thread and post to reply to
 * @return {jQuery} node of the interface created
 */
function createReplyUI(postData) {
  var ui = $('<div>', {
    'class': 'ui-reply'
  });
  // reply content
  var lbContent = $('<label>', {
    'for': 'reply-content-' + postData.post.id,
    'text': AddinMooc_CONFIG.message('UI_REPLY_LABEL_CONTENT')
  });
  ui.append(lbContent);
  var minRows = 3;
  var teaContent = $('<textarea>', {
    'class': 'border-box',
    'id': 'reply-content-' + postData.post.id,
    'rows': minRows
  });
  ui.append(teaContent);
  $(document).on('input.textarea', '#' + teaContent.attr('id'), function() {
    var rows = this.value.split('\n').length;
    this.rows = rows < minRows ? minRows : rows;
  });
  // reply button
  var btnReply = $('<input>', {
    'class': 'btn-reply',
    'type': 'button',
    'value': 'Send reply'
  });
  btnReply.click(function() {
    if (btnReply.prop('disabled')) {
      return;
    }
    btnReply.prop('disabled', true);
    var content = stripPost(teaContent.val());
    if (content.length > 0) {
      var post = postData.post;
      var thread = postData.thread;
      var reply = Post(0, post.level + 1, [ content ], [], createPseudoSignature('--~~~~'));
      post.replies.push(reply);
      // save thread to its talk page
      _index.retrieveItem(function(item) {
        item.discussion = discussion;
        saveThread(item, thread, function() {
          reloadPage('#discussion');
        });
      });
    }
  });
  ui.append(btnReply);
  return ui;
}

/**
 * Renders a post into a node.
 * @param {Object} post instance to render
 * @return {jQuery} node representing the given post
 */
function renderPost(post) {
  // main node
  var nPost = $('<li>', {
    'class': 'post',
    'id': 'post-' + post.id
  });
  // content
  var nContent = $('<div>', {
    'class': 'content'
  });
  // post message
  var nMessage = $('<div>', {
    'class': 'message'
  });
  // message text
  var nMessageText = $('<div>', {
  'class': 'text'
  }).html(post.htmlContent);
  nMessage.append(nMessageText);
  // meta information
  var sSignature = '';
  if (post.signature !== null) {
    sSignature = post.signature.tostring();
  }
  var nMeta = $('<div>', {
    'class': 'meta',
    'text': sSignature
  });
  nMeta.toggle(false);
  nMessage.append(nMeta);
  // reply overlay
  var nOverlay = renderPostOverlay();
  nMessage.prepend(nOverlay);
  nMessage.mouseenter(function() {
    nOverlay.stop(true).fadeIn();
    nMeta.stop(true).fadeIn();
  });
  nMessage.mouseleave(function() {
    nOverlay.stop(true).fadeOut();
    nMeta.stop(true).fadeOut();
  });
  nContent.append(nMessage);
  // replies
  nContent.append(renderReplies(post));
  nPost.append(nContent);
  return nPost;
}

/**
 * Creates the overlay to show a reply interface.
 * @return {jQuery} node of the overlay created
 */
function renderPostOverlay() {
  var nOverlay = $('<div>', {
    'class': 'overlay'
  });
  var nBackground = $('<div>', {
    'class': 'background'
  });
  var nContent = $('<div>', {
    'class': 'content'
  });
  var btnReply = $('<div>', {
    'class': 'btn-reply',
    'text': AddinMooc_CONFIG.message('UI_REPLY_BTN_REPLY')
  });
  var ui = null;
  btnReply.click(function() {// inject reply UI to reply to post
    var visible = false;
    if (ui === null) {
      var nPost = nOverlay.parent().parent().parent();
      var postId = nPost.attr('id').substring(5);
      var postData = findPostInThread(postId);
      ui = createReplyUI(postData);
      nPost.children('.content').children('.replies').append(ui);
    } else {
      visible = ui.css('display') != 'none';
      ui.toggle('fast');
    }
    if (!visible) {
      // hide all other reply UI
      $('.ui-reply').not(ui).toggle(false);
      // scroll to UI
      scrollIntoView(ui, 'bottom');
      ui.children('textarea').focus();
    }
  });
  nContent.append(btnReply);
  nOverlay.append(nBackground);
  nOverlay.append(nContent);
  return nOverlay;
}

/**
 * Renders the replies of a post.
 * @param {Object} post with replies to render
 * @return {jQuery} node with all rendered reply posts
 */
function renderReplies(post) {
  var nReplies = $('<ol>', {
    'class': 'replies'
  });
  for (var i = 0; i < post.replies.length; ++i) {
    nReplies.append(renderReply(post.replies[i]));
  }
  return nReplies;
}

/**
 * Render a reply post into a node.
 * @param {Object} post instance to render
 * @return {jQuery} node representing the given reply post
 */
function renderReply(reply) {
  var nReply = renderPost(reply);
  nReply.addClass('reply');
  return nReply;
}

/**
 * Renders a thread into a node.
 * @param {Object} thread instance to render
 * @return {jQuery} node representing the given thread
 */
function renderThread(thread) {
  var nThread = renderPost(thread);
  nThread.addClass('thread');
  // add thread header containing title and statistics
  var nHeader = renderThreadHeader(thread);
  nThread.prepend(nHeader);
  // make thread collapsible
  nThread.children('.content').addClass('collapsible');
  nHeader.click(function() {
    if (nThread.hasClass('collapsed')) {
      expandThread(nThread, thread);
    } else {
      collapseThread(nThread, thread);
    }
  return false;
  });
  // show warning if unsigned
  if (thread.signature === null) {
    nThread.find('.meta').addClass('warning').text('No one signed this thread.');
  }
  return nThread;
}

/**
 * Creates the header of a certain thread.
 * @param {Object} thread instance to create the header for
 * @return {jQuery} header node created
 */
function renderThreadHeader(thread) {
  // thread title
  var nHeader = $('<h2>', {
    'class': 'header title',
    'text': thread.title
  });
  // number of replies
  var nNumReplies = $('<div>', {
    'class': 'num-replies',
    'text': AddinMooc_CONFIG.message('UI_THREAD_LABEL_HEADER', thread.getNumPosts() - 1)
  });
  nHeader.append(nNumReplies);
  return nHeader;
}

/*####################
  # MEDIAWIKI API WRAPPERS
  # the functions have different intends and
  # 1.) abstract from API calls
  # 2.) chain multiple, successive calls
  ####################*/

/**
 * Request a wiki page's plain wikitext content.
 * Uses 'action=raw' to get the page content.
 * @param {String} title of the wiki page
 * @param {int} section within the wiki page; pass 0 to retrieve whole page
 * @param {function} success callback (String pageContent)
 * @param {function} (optional) failure callback (Object jqXHR: HTTP request object)
 */
function doPageContentRequest(pageTitle, section, sucCallback, errorCallback) {/* Q: feature supported by mw.Api? */
  var url = AddinMooc_CONFIG.get("MW_ROOT_URL") + "?action=raw&title=" + pageTitle;
  if (section !== null) {
    url += "&section=" + section;
  }
  $.ajax({
    url: url,
    cache: false
  }).fail(function(jqXHR) {
    AddinMooc_CONFIG.log(1, 'ERR_WCONTENT_REQ', pageTitle, section, jqXHR.status);
    if (typeof errorCallback !== 'undefined') {
      errorCallback(jqXHR);
    }
  }).done(sucCallback);
}

/**
 * Retrieves edit tokens for any number of wiki pages.
 * @param {Array<String>} page titles of the wiki pages
 * @param {function} success callback (Object editTokens: editTokens.get(pageTitle) = token)
 */
function doEditTokenRequest(pageTitles, sucCallback) {
  var sPageTitles = pageTitles.join('|');
  // get edit tokens
  var tokenData = {
    'intoken': 'edit|watch'
  };
  $.ajax({
    type: "POST",
    url: AddinMooc_CONFIG.get("MW_API_URL") + "?action=query&prop=info&format=json&titles=" + sPageTitles,
    data: tokenData
  }).fail(function(jqXHR) {
    AddinMooc_CONFIG.log(1, 'ERR_WTOKEN_REQ', sPageTitles, jqXHR.status);
  }).done(function(response) {
    var editTokens = parseEditTokens(response);
    if (editTokens.hasTokens()) {
      sucCallback(editTokens);
    } else {
      AddinMooc_CONFIG.log(1, 'ERR_WTOKEN_MISSING', sPageTitles, JSON.stringify(response));
    }
  });
}

/**
 * Edits a wiki page. (non-existing pages will be created automatically)
 * @param {String} title of the wiki page
 * @param {int} section within the wiki page; pass 0 to edit whole page
 * @param {String} edited page content
 * @param {String} edit summary
 * @param {function} success callback
 */
function doEditRequest(pageTitle, section, content, summary, sucCallback) {/* Q: what errors are possible? */
  AddinMooc_CONFIG.log(0, 'LOG_WEDIT', pageTitle, section);
  doEditTokenRequest([ pageTitle ], function(editTokens) {
    var editToken = editTokens.get(pageTitle);
    var editData = {
      'title': pageTitle,
      'text': content,
      'summary': summary,
      'watchlist': 'watch',
      'token': editToken
    };
    if (section !== null) {
      editData.section = section;
    }
    $.ajax({
      type: "POST",
      url: AddinMooc_CONFIG.get("MW_API_URL") + "?action=edit&format=json",
      data: editData
    }).fail(function(jqXHR) {
      AddinMooc_CONFIG.log(1, 'ERR_WEDIT_REQ', pageTitle, jqXHR.status);
    }).done(function(response) {//TODO handle errors
      AddinMooc_CONFIG.log(0, 'LOG_WEDIT_RES', JSON.stringify(response));
      sucCallback();
    });
  });
}

/**
 * Adds a section to a wiki page. (non-existing pages will be created automatically)
 * @param {String} title of the wiki page
 * @param {String} title of the new section
 * @param {String} content of the new section
 * @param {String} edit summary
 * @param {function} success callback
 */
function addSectionToPage(pageTitle, sectionTitle, content, summary, sucCallback) {
  AddinMooc_CONFIG.log(0, 'LOG_WADD', pageTitle, sectionTitle);
  doEditTokenRequest([ pageTitle ], function(editTokens) {
    var editToken = editTokens.get(pageTitle);
    var editData = {
      'title': pageTitle,
      'section': 'new',
      'sectiontitle': sectionTitle,
      'text': content,
      'summary': summary,
      'watchlist': 'watch',
      'token': editToken
    }; 
    $.ajax({
      type: "POST",
      url: AddinMooc_CONFIG.get("MW_API_URL") + "?action=edit&format=json",
      data: editData
    }).fail(function(jqXHR) {
      AddinMooc_CONFIG.log(1, 'ERR_WADD_REQ', pageTitle, sectionTitle, jqXHR.status);
    }).done(function(response) {//TODO handle errors
      AddinMooc_CONFIG.log(0, 'LOG_WADD_RES', JSON.stringify(response));
      sucCallback();
    });
  });
}

/**
 * Parses a server response containing one or multiple edit tokens.
 * @param {JSON} tokenResponse
 * @return {Object} edit tokens object - you can retrieve the edit token by passing the page title to the object's 'get'-function
 */
function parseEditTokens(tokenResponse) {
  var hasTokens = false;
  var editTokens = {
    'tokens': [],
    'add': function(title, edittoken) {
      var lTitle = title.toLowerCase();
      AddinMooc_CONFIG.log(0, 'LOG_WTOKEN_TOKEN', title, edittoken);
      this.tokens[lTitle] = edittoken;
      hasTokens = true;
    },
    'get': function(title) {
      return this.tokens[title.toLowerCase()];
    },
    'hasTokens': function() {
      return hasTokens;
    }
  };
  var path = ['query', 'pages'];
  var crr = tokenResponse;
  for (var i = 0; i < path.length; ++i) {
    if (crr && crr.hasOwnProperty(path[i])) {
      crr = crr[path[i]];
    } else {
      AddinMooc_CONFIG.log(1, 'ERR_WTOKEN_PARSING', path[i]);
      crr = null;
      break;
    }
  }
  if (crr) {
    var pages = crr;
    for (var pageId in pages) {
      // page exists
      if (pages.hasOwnProperty(pageId)) {
        var page = pages[pageId];
        editTokens.add(page.title, page.edittoken);
      }
    }
  }
  return editTokens;
}

/**
 * Retrieves the index of a MOOC.
 * @param {String} title of the MOOC index page
 * @param {int} section within the index page; pass 0 to retrieve whole page
 * @param {function} success callback (String indexContent)
 */ 
function getIndex(title, section, sucCallback) {
  doPageContentRequest(title, section, sucCallback);
}

/**
 * Retrieves the script of a MOOC item.
 * @param {Object} MOOC item
 * @param {function} success callback (String scriptContent)
 * @param {function} (optional) failure callback (Object jqXHR: HTTP request object)
 */
function getScript(item, sucCallback, errorCallback) {
  doPageContentRequest(item.fullPath + '/script', 0, sucCallback, errorCallback);
}

/**
 * Retrieves the quiz of a MOOC item.
 * @param {Object} MOOC item
 * @param {function} success callback (String quizContent)
 * @param {function} (optional) failure callback (Object jqXHR: HTTP request object)
 */
function getQuiz(item, sucCallback, errorCallback) {
  doPageContentRequest(item.fullPath + '/quiz', 0, sucCallback, errorCallback);
}

/**
 * Updates the script of a MOOC item.
 * @param {Object} MOOC item
 * @param {String} updated script content
 * @param {String} edit summary; uses generated summary if empty
 * @param {function} success callback
 */
function updateScript(item, scriptText, summary, sucCallback) {
  var editSummary = summary;
  if (editSummary === '') {
    editSummary = 'script update for MOOC ' + item.header.type + ' ' + item.fullPath;
  }
  doEditRequest(item.fullPath + '/script', 0, scriptText, editSummary, sucCallback);
}

/**
 * Updates the quiz of a MOOC item.
 * @param {Object} MOOC item
 * @param {String} updated quiz content
 * @param {String} edit summary; uses generated summary if empty
 * @param {function} success callback
 */
function updateQuiz(item, quizText, summary, sucCallback) {
  var editSummary = summary;
  if (editSummary === '') {
    editSummary = 'quiz update for MOOC ' + item.header.type + ' ' + item.fullPath;
  }
  doEditRequest(item.fullPath + '/quiz', 0, quizText, editSummary, sucCallback);
}

/**
 * Updates the MOOC index containing the given MOOC item.
 * @param {Object} MOOC item
 * @param {String} edit summary appendix; will be appended to a generated summary specifying the MOOC item passed
 * @param {function} success callback
 */
function updateIndex(item, summaryAppendix, sucCallback) {
  var summary = item.header.type + ' ' + item.header.path + ': ' + summaryAppendix;
  if (item.header.path === null) {// changing root item
    summary = item.header.type + ':' + summaryAppendix;
  }
  doEditRequest(item.index.title, item.indexSection, item.tostring(), summary, sucCallback);
}

/**
 * Creates a wiki page.
 * @param {String} title of the new page
 * @param {String} content of the new page
 * @param {String} edit summary
 * @param {function} success callback
 */
function createPage(pageTitle, content, summary, sucCallback) {
  doEditRequest(pageTitle, 0, content, summary, sucCallback);
}

/**
 * Adds a child item to a MOOC item.
 * @param {String} type of the new item
 * @param {String} name of the new item
 * @param {Object} MOOC item the child will be added to
 * @param {String} edit summary appendix for MOOC index edit; uses generated summary appendix if empty
 * @param {function} success callback
 */
function addChild(type, name, parent, summary, sucCallback) {
  // add item to parent
  var parentHeader = parent.header;
  var header = Header(parentHeader.level + 1, type, name, null);
  parent.childLines.push(header.tostring());
  AddinMooc_CONFIG.log(0, 'LOG_ADD_CHILD', parentHeader.type, parentHeader.title, header.tostring());
  // update MOOC index at parent position
  var itemIdentifier = type + ' ' + parentHeader.path + '/' + name;
  if (parentHeader.path === null) {// parent is root
    itemIdentifier = type + ' ' + name;
  }
  if (summary === '') {
    summary = itemIdentifier + ' added';
  }
  updateIndex(parent, summary, function() {
    // create item page
    doEditRequest(parent.fullPath + '/' + name, 0, parent.getInvokeCode(), 'invoke page for MOOC ' + itemIdentifier + ' created', sucCallback);
  });
}

/**
 * Adds a lesson to a MOOC.
 * @param {String} lesson name
 * @param {Object} MOOC root item
 * @param {String} edit summary appendix for MOOC index edit; uses generated summary appendix if empty
 * @param {function} success callback
 */
function addLesson(name, item, summary, sucCallback) {
  addChild('lesson', name, item, summary, sucCallback);
}

/**
 * Adds an unit to a MOOC lesson.
 * @param {String} unit name
 * @param {Object} lesson the unit will be added to
 * @param {String} edit summary appendix for MOOC index edit; uses generated summary appendix if empty
 * @param {function} success callback
 */
function addUnit(name, item, summary, sucCallback) {
  addChild('unit', name, item, summary, sucCallback);
}

/**
 * Creates a MOOC.
 * @param {String} MOOC name
 * @param {String} edit summary for category, MOOC overview and MOOC index page
 * @param {function} success callback
 */
function createMooc(title, summary, sucCallback) {
  createPage('Category:' + title, '{{#invoke:Mooc|overview|base=' + title + '}}\n<noinclude>[[category:MOOC]]</noinclude>', summary, function() {// create category with overview
    createPage(title, '{{#invoke:Mooc|overview|base=' + title + '}}', summary, function() {// create MOOC overview page
      createPage(title + '/MoocIndex', '--MoocIndex for MOOC @ ' + title, summary, sucCallback);// create MOOC index
    });
  });
}

/**
 * Adds a thread to a talk page belonging to a MOOC item. Updates the item's discussion statistic in MOOC index.
 * @param {Object} MOOC item the talk page belongs to
 * @param {Object} talk page object
 * @param {String} title of the new thread
 * @param {String} content of the new thread
 * @param {function} success callback
 */
function addThread(item, talkPage, title, content, sucCallback) {//TODO: use updateIndex
  item.setParameter(PARAMETER_KEY.NUM_THREADS, (item.discussion.threads.length + 1).toString());
  item.setParameter(PARAMETER_KEY.NUM_THREADS_OPEN, (item.discussion.getNumOpenThreads() + 1).toString());
  addSectionToPage(talkPage.title, title, content, 'q:' + title, function() {
    // update discussion statistic in MOOC index
    doEditRequest(item.index.title, item.indexSection, item.tostring(), 'new thread in item discussion', sucCallback);
  });
}

/**
 * Updates a thread on a talk page belonging to a MOOC item. Updates the item's discussion statistic in MOOC index.
 * @param {Object} MOOC item the talk page belongs to
 * @param {Object} thread object
 * @param {function} success callback
 */
function saveThread(item, thread, sucCallback) {//TODO: use updateIndex
  item.setParameter(PARAMETER_KEY.NUM_THREADS, item.discussion.threads.length.toString());
  item.setParameter(PARAMETER_KEY.NUM_THREADS_OPEN, item.discussion.getNumOpenThreads().toString());
  doEditRequest(thread.talkPage.title, thread.section, thread.tostring(), 'replied to "' + thread.title + '"', function() {
    // update discussion statistic in MOOC index
    doEditRequest(item.index.title, item.indexSection, item.tostring(), 'new reply in item discussion', sucCallback);
  });
}

/**
 * Parses wikitext.
 * @param {String} wikitext to be parsed
 * @param {function} success callback (String parsedWikitext)
 * @param {function} (optional) failure callback
 */
function parseThreads(unparsedContent, sucCallback, errCallback) {
  AddinMooc_CONFIG.log(0, 'LOG_WPARSE', unparsedContent);
  var api = new mw.Api();
  var promise = api.post({
    'action': 'parse',
    'contentmodel': 'wikitext',
    'disablepp': true,
    'text': unparsedContent
  });
  promise.done(function(response) {
    AddinMooc_CONFIG.log(0, 'LOG_WPARSE_RES', JSON.stringify(response));
    var wikitext = response.parse.text['*'];
    sucCallback(wikitext);
  });
  if (typeof errCallback !== 'undefined') {
    promise.fail(errCallback);
  }
}

/**
 * Retrieves the URLs of any number of video files.
 * @param {Array<String>} array of titles of the files to retrieve an URL for (WARNING: should not include '_' to access the URL mapping in success callback correctly)
 * @param {function} callback when the URLs were retrieved successfully (An array mapping (page title) -> (url) will be passed. The page titles will not contain '_' but spaces.)
 */
function getVideoUrls(fileTitles, sucCallback) {
  //WTF: imageinfo does also work on video files
  var sFileTitles = fileTitles.join('|');
  var api = new mw.Api();
  api.get({
    action: 'query',
    prop: 'videoinfo',
    titles: sFileTitles,
    viprop: 'url'
  }).done(function(data) {
    var path = ['query', 'pages'];
    var crr = data;
    for (var i = 0; i < path.length; ++i) {
      if (crr && crr.hasOwnProperty(path[i])) {
        crr = crr[path[i]];
      } else {
        AddinMooc_CONFIG.log(1, 'ERR_WQUERY_VIDEO', path[i]);
        crr = null;
        break;
      }
    }
    var fileUrls = [];
    if (crr) {
      var pages = crr;
      for (var pageId in pages) {
        // page exists
        if (pages.hasOwnProperty(pageId)) {
          var page = pages[pageId];
          fileUrls[page.title] = page.videoinfo[0].url;
          AddinMooc_CONFIG.log(0, 'LOG_WQUERY_VIDEO_URL', page.title, page.videoinfo[0].url);
        }
      }
      sucCallback(fileUrls);
    }
  });
}

/*####################
  # INDEX UTILITIES
  # helper functions to load objects from and work with the MOOC index
  ####################*/
 
 /**
 * Creates a header instance holding identification data of the MOOC item.
 * @param {int} item level
 * @param {String} item type
 * @param {String} item title
 * @param {String} item path (relative to MOOC base)
 * @return {Object} MOOC item header to identify the item and write to MOOC index
 */
function Header(level, type, title, path) {
  return {
    'level': level,
    'path': path,
    'title': title,
    'type': type,
    'tostring': function() {
      var intendation = strrep('=', this.level);
      return intendation + this.type + '|' + this.title + intendation;
    }
  };
}

/**
 * Creates an item instance holding data extracted from MOOC index.
 * @param {Object} item header
 * @param {Object} MOOC index
 * @return {Object} MOOC item to get parameters and write to MOOC index
 */
function Item(header, index) {
  var loadingScript = false;
  var loadingQuiz = false;
  return {
    'childLines': [],
    'discussion': null,
    'fullPath': _fullPath,
    'header': header,
    'index': index,
    'indexSection': index.itemSection,
    'parameterKeys': [],
    'parameters': {},
    'script': null,
    'quiz': null,
    /**
     * @return {String} invoke code used for the current item
     */
    'getInvokeCode': function() {
      return '{{#invoke:Mooc|render|base=' + index.base + '}}';
    },
    /**
     * Gets the value for an item parameter.
     * @param {String} parameter key
     * @return {?} Value stored for the parameter key passed. May be undefined.
     */
    'getParameter': function(key) {
      return this.parameters[key];
    },
    /**
     * Sets the value for an item parameter.
     * @param {String} parameter key
     * @param {?} parameter value
     */
    'setParameter': function(key, value) {
      this.parameters[key] = value;
      if ($.inArray(key, this.parameterKeys) == -1) {
        this.parameterKeys.push(key);
      }
    },
    /**
     * Retrieves the script resource for this item.
     * @param {function} success callback (String scriptContent)
     * @param {function} (optional) failure callback (Object jqXHR: HTTP request object)
     */
    'retrieveScript': function(sucCallback, errCallback) {
      if (this.script !== null) {
        sucCallback(this.script);
      } else if (!loadingScript) {
        loadingScript = true;
        getScript(this, sucCallback, errCallback);
      } else {
        // does not happen
      }
    },
    /**
     * Retrieves the quiz resource for this item.
     * @param {function} success callback (String quizContent)
     * @param {function} (optional) failure callback (Object jqXHR: HTTP request object)
     */
    'retrieveQuiz': function(sucCallback, errCallback) {
      if (this.quiz !== null) {
        sucCallback(this.quiz);
      } else if (!loadingQuiz) {
        loadingQuiz = true;
        getQuiz(this, sucCallback, errCallback);
      } else {
        // does not happen
      }
    },
    /**
     * @return MOOC index content for this item
     */
    'tostring': function() {
      var lines = [];
      // header line
      if (this.indexSection !== null) {// except root item
        lines.push(this.header.tostring());
      }
      // parameters
      var key, value;
      this.parameterKeys.sort();
      for (var i = 0; i < this.parameterKeys.length; ++i) {
        key = this.parameterKeys[i];
        value = this.parameters[key];
        if (value.indexOf("\n") != -1) {// linebreak for multi line values
          lines.push('*' + key + '=\n' + value);
        } else {
          lines.push('*' + key + '=' + value);
        }
      }
      // children
      for (var c = 0; c < this.childLines.length; ++c) {
        lines.push(this.childLines[c]);
      }
      return lines.join('\n');
    }
  };
}

/**
 * Creates a MOOC index instance providing read access.
 * @param {String} MOOC page title
 * @param {String} MOOC base
 * @return {Object} MOOC index instance to retrieve item
 */
function MoocIndex(title, base) {
  var isLoading = false;
  return {
    'base': base,
    'item': null,
    'itemPath': null,
    'itemSection': null,
    'title': title,
    /**
     * Sets the current item. MUST be called before using this object.
     * @param {int} section of the item within the MOOC index
     * @param {String} absolute path of the item
     */
    'useItem': function(section, path) {
      this.itemSection = section;
      this.itemPath = path;
    },
    /**
     * Retrieves the current item from the MOOC index.
     * If the item is not cached this call will trigger a network request.
     * @param {function} success callback (String indexContent)
     * @param {function} (optional) failure callback (Object jqXHR: HTTP request object)
     */
    'retrieveItem': function(sucCallback, errCallback) {
      if (this.item !== null) {
        sucCallback(this.item);
      } else {
        var index = this;
        if (!isLoading) {// retrieve index and load item
          isLoading = true;
          getIndex(this.title, this.itemSection, function(indexContent) {
            var indexLines = splitLines(indexContent);
            var item;

            if (index.itemSection === null) {// root item
              item = Item(Header(0, 'mooc', index.base, null), index);
              // do not interprete index
              for (var i = 0; i < indexLines.length; ++i) {
                item.childLines.push(indexLines[i]);
              }
            } else {// index item
              var header = loadHeader(indexLines[0], index.base, index.itemPath);
              item = Item(header, index);
              // load properties and lines of child items
              var childLines = false;
              for (var i = 1; i < indexLines.length; i++) {
                if (!childLines) {
                  if (getLevel(indexLines[i]) > 0) {
                    childLines = true;
                  } else {
                    var property = loadProperty(indexLines, i);
                    item.setParameter(property.key, property.value);
                    i = property.iEnd;
                  }
                }
                if (childLines) {
                  item.childLines.push(indexLines[i]);
                }
              }
            }
            index.item = item;
            isLoading = false;
            sucCallback(item);
          });
        } else {// another process triggered network request
          setTimeout(function() {
            if (index.item !== null) {
              sucCallback(index.item);
            } else if (!isLoading && typeof(errCallback) !== 'undefined') {
              errCallback();
            }
          }, 100);
        }
      }
    }
  };
}

 /**
 * Loads the header of a MOOC item from its index header line.
 * @param {String} item's header line from MOOC index
 * @param {String} MOOC base
 * @param {String} item path (absolute path including MOOC base)
 * @return {Object} MOOC item header loaded from header line. Returns null if header line malformed.
 */
function loadHeader(line, base, fullPath) {
  var level = getLevel(line);
  if (level > 0) {
    var iSeparator = line.indexOf('|');
    if (iSeparator > -1) {
      var type = line.substring(level, iSeparator);
      var title = line.substring(iSeparator + 1, line.length - level);
      var path = fullPath.substring(base.length + 1);// relative path
      AddinMooc_CONFIG.log(0, 'LOG_INDEX_HEADER', type, title, level, path);
      return Header(level, type, title, path);
    }
  }
  AddinMooc_CONFIG.log(1, 'ERR_INDEX_HEADER', line);
  return null;
}

/**
 * Loads a parameter of an item.
 * @param {Array} MOOC index lines
 * @param {int} start index of the parameter within index
 * @return {Object} item parameter extracted (key, value) and index of last line related to parameter (iEnd)
 */
function loadProperty(indexLines, iLine) {
  var line = indexLines[iLine];
  var iSeparator = line.indexOf('=');
  if (iSeparator != -1) {
    var paramLines = [];
    var key = line.substring(1, iSeparator);
    // read parameter value
    var i = iLine;
    var value = line.substring(iSeparator + 1);
    do {
      if (i > iLine) {// multiline value
        if (paramLines.length === 0 && value.length > 0) {// push first line value if any
          paramLines.push(value);
        }
        paramLines.push(line);
      }
      i += 1;
      line = indexLines[i];
    } while(i < indexLines.length && line.substring(0, 1) !== '*' && getLevel(line) === 0);
    i -= 1;

    if (paramLines.length > 0) {
      value = paramLines.join('\n');
    }
    return {
      'iEnd': i,
      'key': key,
      'value': value
    };
  }
  return null;
}

/*####################
  # DISCUSSION UTILITIES
  # helper functions to work with discussion objects
  ####################*/

/**
 * Creates a discussion instance holding data extracted from a number of talk pages.
 * @return {Object} discussion instance d with
 * * {int} d.lastId: highest post identifier used
 * * d.lost
 * * {Array<Object>} d.talkPages: talk page instances
 * * {Array<Object>} d.threads: threads loaded from the talk pages
 * * {int} d.getNumPosts()
 * * {int} d.getNumOpenThreads()
 * * {String} d.tostring()
 */
function Discussion() {
  return {
    'lastId': 0,
    'lost': [],
    'talkPages' : [],
    'threads': [],
    'getNumPosts': function() {
      var numPosts = 0;
      for (var i = 0; i < this.threads.length; ++i) {
        numPosts += this.threads[i].getNumPosts();
      }
      return numPosts;
    },
    'getNumOpenThreads': function() {
      var numOpenThreads = 0;
      for (var i = 0; i < this.threads.length; ++i) {
        if (this.threads[i].getNumPosts() === 1) {
          numOpenThreads += 1;
        }
      }
      return numOpenThreads;
    },
    'tostring': function() {
      var value = [];
      for (var i = 0; i < this.threads.length; ++i) {
        value.push(this.threads[i].tostring());
      }
      for (var j = 0; j < this.lost.length; ++j) {
        value.push(this.lost[j]);
      }
      return value.join('\n');
    }
  };
}

/**
 * Creates a post instance holding data extracted from the talk page.
 * @param {int} unique post identifier
 * @param {int} post level
 * @param {Array<String>} post content lines
 * @param {Array<Object>} posts that are in reply to this post
 * @param {Object} signature object
 * @return {Object} post instance p with
 * * {int} p.id
 * * {String} p.content
 * * {String} p.htmlContent
 * * {Array<Object>} p.replies
 * * {Object} p.signature
 * * {int} p.getNumPosts()
 * * {boolean} p.isValid()
 * * {String} p.tostring()
 */
function Post(id, level, content, replies, signature) {
  return {
    'id': id,
    'content': content.join('\n'),
    'htmlContent': this.content,
    'level': level,
    'replies': replies,
    'signature': signature,
    'getNumPosts': function() {
      var num = 1;
      for (var i = 0; i < this.replies.length; ++i) {
        num += this.replies[i].getNumPosts();
      }
      return num;
    },
    'isValid': function() {
      // post malformed: signature missing
      return (this.signature !== null);
    },
    'tostring': function() {
      var value = [];
      var firstLine = strrep(':', this.level) + this.content;
      if (this.signature !== null) {
        firstLine += ' ' + this.signature.towikitext();
      }
      value.push(firstLine);
      for (var i = 0; i < this.replies.length; ++i) {
        value.push(this.replies[i].tostring());
      }
      return value.join('\n');
    }
  };
}

/**
 * Creates a talk page instance.
 * @param {String} title of the talk page
 * @return {Object} talk page instance t with
 * * {Array<Object>} t.threads: threads on the talk page
 * * {String} t.title: talk page title
 */
function TalkPage(title) {
  return {
    'threads': [],
    'title': title
  };
}

/**
 * Creates a thread instance holding data extracted from the talk page.
 * @param {String} thread title (gets stripped from leading/trailing whitespace)
 * @param {int} section of the thread within the talk page
 * @return {Object} thread instance t being a post instance with
 * * {Array<String>} t.content
 * * {Array<String>} t.lost
 * * {int} t.published
 * * {int} t.section
 * * {String} t.title
 */
function Thread(title, section) {
  var thread = Post(0, 0, [], [], null);
  thread.content = [];
  thread.lost = [];
  thread.published = 0;
  thread.section = section;
  thread.title = $.trim(title);
  thread.tostring = function() {
    var value = [];
    value.push('==' + this.title + '==');
    value.push(this.content);
    if (this.signature !== null) {
      value.push(this.signature.towikitext());
    }
    for (var i = 0; i < this.replies.length; ++i) {
      value.push(this.replies[i].tostring());
    }
    return value.join('\n');
  };
  return thread;
}

/**
 * Creates an empty signature object.
 * @param {String} value the object's "towikitext" function returns
 * @return {Object} empty signature object returning the given value in it's "towikitext" function
 */
function createPseudoSignature(value) {
  return {
    'towikitext': function() {
      return value;
    }
  };
}

/**
 * Cuts thread content to a certain length as a preview.
 * @param {Object} thread with content to be cut
 * @param {int} maximum number of characters for the preview
 * @return {String} thread content with maximum length passed plus '...'
 */
function cutThreadContent(thread, maxLength) {
  var div = document.createElement('div');
  div.innerHTML = thread.htmlContent;
  var text = div.textContent || div.innerText || '';
  
  if (thread.htmlContent.length <= maxLength) {
    return thread.htmlContent;
  }
  var cutContent = [];
  var crrLength = 0;
  var words = text.split(/\s/);
  for (var i = 0; i < words.length; ++i) {
    crrLength += words[i].length + 1;
    if (crrLength > maxLength) {
      break;
    }
    cutContent.push(words[i]);
  }
  return cutContent.join(' ') + '...';
}

/**
 * Searches a post for a certain post identifier. Includes post specified and all its replies.
 * @param {Object} post instance to search in
 * @param {int} searched post identifier
 * @return {Object} o with
 * * {Object} o.post: post instance with searched identifier
 * or null if not found in post specified
 */
function findPostInPost(post, postId) {
  if (post.id == postId) {
    return {
      'post': post
    };
  }
  for (var i = 0; i < post.replies.length; ++i) {
    var reply = post.replies[i];
    var result = findPostInPost(reply, postId);
    if (result !== null) {
      return result;
    }
  }
  return null;
}

/**
 * Searches all threads for a certain post identifier.
 * @param {int} searched post identifier
 * @return {Object} o with
 * * {Object} o.post: post instance with searched identifier
 * * {Object} o.thread: thread instance the post belongs to
 * or null if not found
 */
function findPostInThread(postId) {
  for (var i = 0; i < discussion.threads.length; ++i) {
    var result = findPostInPost(discussion.threads[i], postId);
    if (result !== null) {
      result.thread = discussion.threads[i];
      return result;
    }
  }
  return null;
}

/**
 * Retrieves the instance of a certain talk page. Creates a new instance if not existing.
 * @param {String} title of the talk page
 * @return {Object} talk page instance
 */
function getTalkPage(talkPageTitle) {
  for (var i = 0; i < discussion.talkPages.length; ++i) {
    if (discussion.talkPages[i].title === talkPageTitle) {
      return discussion.talkPages[i];
    }
  }
  // return empty talk page object
  return TalkPage(talkPageTitle);
}

/**
 * Loads a post from a talk page.
 * @param {Array<String>} text lines containing the post
 * @param {int} index of the line the post starts at
 * @param {int} highest used post identifier
 * @return {Object} o with
 * * {Object} o.post: post instance
 * * {int} o.iEnd: index of the line the post ends at
 * * {int} o.iLastId: highest used post identifier
 */
function loadPost(lines, iStart, lastId) {
  // get level
  var firstLine = lines[iStart];
  var level = getPostLevel(firstLine);
  firstLine = firstLine.substring(level);
  var content = [];
  var signature = loadSignature(firstLine);
  if (signature === null) {
    content.push(firstLine);
  }
  var id = ++lastId;
  
  var nextLevel;
  var line;
  var i = iStart + 1;
  var replies = [];
  while (i < lines.length) {
    line = lines[i];
    nextLevel = getPostLevel(line);
    if (nextLevel > 0 || line.length > 0) {
      if (nextLevel < level) {// new post at higher level
        break;
      } else if (nextLevel > level) {// reply
        var reply = loadPost(lines, i, lastId);
        if (reply.post.isValid()) {
          replies.push(reply.post);
        }
        i = reply.iEnd;
        lastId = reply.lastId;
      }
    }
    if (nextLevel == level) {// post at same level: signature determines
      if (signature !== null) {// new post at same level
        break;
      } else {// still current post: add and search for signature
        line = line.substring(level);
        signature = loadSignature(line);
        if (signature === null) {// signature not found: add content
          content.push(line);
        }
        i += 1;
      }
    } else if (nextLevel === 0 && line.length === 0) {// ignore blank line
      i += 1;
    }
  }
  if (signature !== null) {// signature found: add signature line content if any
    if (signature.content !== null) {
      content.push(signature.content);
    }
    signature = signature.value;
  }
  
  return {
    'post': Post(id, level, content, replies, signature),
    'iEnd': i,
    'lastId': lastId
  };
}

/**
 * Loads a signature object from a wikitext signature.
 * @param {String} line ending with a wikitext signature
 * @return {Object} o with
 * * {String} o.content: text in line before signature
 * * {Object} o.value: signature object
 */
function loadSignature(line) {
  var pos = line.indexOf('--[[');
  if (pos != -1) {
    var content = null;
    if (pos > 0) {
      content = line.substring(0, pos);
    }
    var value = line.substring(pos);
    
    // parse timestamp
    var vcopy = value.substring(4);
    var sNamespaceUser = AddinMooc_CONFIG.get('MW_NAMESPACE_USER') + ':';
    var sPageContributions = AddinMooc_CONFIG.get('MW_PAGE_CONTRIBUTIONS') + '/';
    var username = null;
    
    if (vcopy.substring(0, sNamespaceUser.length) == sNamespaceUser) {// user signature e.g. "--[[Benutzer:Sebschlicht|Sebschlicht]] ([[Benutzer Diskussion:Sebschlicht|Diskussion]]) 10:20, 12. Nov. 2014 (CET)"
      var patternUsername = new RegExp('[^\|]*');
      username = vcopy.match(patternUsername);
      if (username !== null) {
        username = username[0];
        username = username.substring(sNamespaceUser.length);
      }
    } else if (vcopy.substring(0, sPageContributions.length) == sPageContributions) {// IP signature e.g. "--[[Special:Contributions/81.17.28.58|81.17.28.58]] ([[User talk:81.17.28.58|discuss]]) 10:00, 23 October 2013 (UTC)"
      var patternIpAddress = new RegExp('[^\|]*');
      username = vcopy.match(patternIpAddress);
      if (username !== null) {
        username = username[0];
        username = username.substring(sPageContributions.length);
        console.log('IP address: ' + username);
      }
    }
    
    if (username != null) {
      var sTimestamp = vcopy.match(/\).+?$/);
      if (sTimestamp == null) {
      	console.log('german IP signature');
        sTimestamp = vcopy.match(/\].+?$/);
        if (sTimestamp !== null) {
          sTimestamp[0] = sTimestamp[0].substring(1);
        }
      }
      if (sTimestamp !== null) {
        sTimestamp = sTimestamp[0];
        sTimestamp = sTimestamp.substring(2);
        var timestamp = parseTimestamp(sTimestamp);
        console.log('timestamp: ' + timestamp);
        
        return {//TODO: create and use constructor
          'content': content,
          'value': {
            'author': username,
            'timestamp': timestamp,
            'tostring': function() {
              return AddinMooc_CONFIG.message('UI_POST_SIGNATURE', dateToString(this.timestamp), this.author);
            },
            'towikitext': function() {
              return value;
            }
          }
        };
      }
    }
  }
  // unsigned or malformed signature
  return null;
}

/**
 * Loads a thread. Separates thread content from replies.
 * @param {Object} thread instance t with all text belonging to the thread in t.content
 * @param {int} highest used post identifier
 * @return {Object} o with
 * * {int} o.lastId: highest used post identifier
 * * {Object} o.value: thread instance loaded
 */
function loadThread(thread, lastId) {
  var lines = thread.content;
  var content = [];
  var i = 0;
  var signature = null;
  var id = ++lastId;
  while (i < lines.length) {
    var level = getPostLevel(lines[i]);
    if (level > 0) {// post
      var post = loadPost(lines, i, lastId);
      i = post.iEnd;
      lastId = post.lastId;
      if (post.post.isValid()) {
        thread.replies.push(post.post);
      } else {//copy invalid posts to thread's lost section
        thread.lost.push(post.post.content);
        AddinMooc_CONFIG.log(0, 'LOG_DIS_POST_INVALID', post.post.id, post.post.content);
      }
    } else {// thread content
      if (signature === null) {// no signature found yet
        signature = loadSignature(lines[i]);
        if (signature === null) {// no signature found: add content
          content.push(lines[i]);
        } else if (signature.content !== null) {// signature found: add signature line content if any
          content.push(signature.content);
        }
      } else {// signature already found: malformed?
        content.push(lines[i]);
      }
      i += 1;
    }
  }
  // remove trailing newlines
  if (content[0] == '') {
    content.shift();
  }
  if (content[content.length - 1] == '') {
    content.pop();
  }
  thread.id = id;
  thread.content = content.join('\n');
  if (signature !== null) {
    thread.signature = signature.value;
    thread.published = thread.signature.timestamp.getTime();
  }
  return {
    'lastId': lastId,
    'value': thread
  };
}

/**
 * Loads all threads of a talk page.
 * @param {Array<String>} talk page content
 * @param {int} highest used post identifier
 * @return {Object} o with
 * * {int} o.lastId: highest used post identifier
 * * {Array<String>} o.lost
 * * {Array<Object>} o.threads: threads the talk page contained
 */
function loadThreads(lines, lastId) {
  var rawThreads = splitThreads(lines);
  var threads = [];
  // load threads with their replies
  for (var i = 0; i < rawThreads.threads.length; ++i) {
    var thread = loadThread(rawThreads.threads[i], lastId);
    lastId = thread.lastId;
    threads.push(thread.value);
  }
  return {
    'lastId': lastId,
    'lost': rawThreads.lost,
    'threads': threads
  };
}

/**
 * Loads the threads of a number of talk pages into a discussion instance.
 * @param {Array<String>} titles of the talk pages to load
 * @param {int} index within passed titles of the talk page to load
 * @param {Object} discussion instance to push the threads to
 * @param {function} finish callback
 */
function mergeThreads(talkPageTitles, iCrrPage, discussion, callback) {
  if (iCrrPage < talkPageTitles.length) {
    var talkPage = TalkPage(talkPageTitles[iCrrPage]);
    doPageContentRequest(talkPage.title, null, function(pageContent) {
      var lines = splitLines(pageContent);
      var parsed = loadThreads(lines, discussion.lastId);
      var pageThreads = parsed.threads;
      for (var t = 0; t < pageThreads.length; ++t) {
        var pageThread = pageThreads[t];
        pageThread.talkPage = talkPage;
        talkPage.threads.push(pageThread);
        discussion.talkPages.push(talkPage);
        discussion.threads.push(pageThread);
      }
      discussion.lastId = parsed.lastId;
      mergeThreads(talkPageTitles, iCrrPage + 1, discussion, callback);
    }, function() {// failed to retrieve talk page, assume it just does not exist and go on
      mergeThreads(talkPageTitles, iCrrPage + 1, discussion, callback);
    });
  } else {
    callback();
  }
}

/**
 * Loads threads from talk pages, injects them into discussion section and enables global on-page discussion.
 * @param {Array<String>} titles of the talk pages to load from
 */
function renderThreads(talkPageTitles) {
  mergeThreads(talkPageTitles, 0, discussion, function() {
    // insert ask question UI
    insertAskQuestionUI('learningGoals', talkPageTitles[0], false);
    insertAskQuestionUI('video', talkPageTitles[0], true);
    insertAskQuestionUI('script', talkPageTitles[1], true);
    insertAskQuestionUI('quiz', talkPageTitles[2], true);
    insertAskQuestionUI('furtherReading', talkPageTitles[0], false);
    insertAskQuestionUI('discussion', talkPageTitles[0], true);
    
    var threads = discussion.threads;
    AddinMooc_CONFIG.log(0, 'LOG_DIS_NUMTHREADS', threads.length, talkPageTitles);
    threads.sort(function(t1, t2) {// sort threads by timestamp of publication (DESC)
      if (t1.published > t2.published) {
        return -1;
      } else if (t1.published < t2.published) {
        return 1;
      } else {
        return 0;
      }
    });
    
    var injectThreads = function() {
      var divDiscussion = $('#discussion > .content');
      for (var j = 0; j < threads.length; ++j) {
        var nThread = renderThread(threads[j]);
        divDiscussion.append(nThread);
        if (threads.length > 2) {// collapse threads if too many
          collapseThread(nThread, threads[j]);
        }
        
        if (!threads[j].isValid()) {// thread invalid: unsigned
          // TODO show warning(s) below threads
        }
        if (threads[j].lost.length > 0) {// contains invalid posts: unsigned
          // TODO show warning(s) below threads
        }
      }
    };
    
    // parse thread content and inject threads
    var getContentNodes = function(post) {
      var nodes = [];
      nodes.push($('<div>', {'id':post.id}).html(post.content));
      for (var i = 0; i < post.replies.length; ++i) {
        nodes = nodes.concat(getContentNodes(post.replies[i]));
      }
      return nodes;
    };
    var nContent = $('<div>');
    for (var i = 0; i < threads.length; ++i) {
      var nodes = getContentNodes(threads[i]);
      for (var n = 0; n < nodes.length; ++n) {
        nContent.append(nodes[n]);
      }
    }
    parseThreads(nContent.html(), function(parsedContent) {
      var nThreads = $.parseHTML(parsedContent);
      var adoptContentNodes = function(post, nodes, iCrr) {
        post.htmlContent = nodes[iCrr].html();
        iCrr += 1;
        for (var i = 0; i < post.replies.length; ++i) {
          iCrr = adoptContentNodes(post.replies[i], nodes, iCrr);
        }
        return iCrr;
      };
      var nodes = [];
      $.each(nThreads, function(i, el) {
        var nThread = $(el);
        if (typeof nThread.attr('id') !== 'undefined') {
          nodes.push($(el));
        }
      });
      var iCrr = 0;
      for (var i = 0; i < threads.length; ++i) {
        iCrr = adoptContentNodes(threads[i], nodes, iCrr);
      }
      injectThreads();
    }, function() {// inject unparsed threads if parsing failed
      injectThreads();
    });
  });
}

/**
 * Splits a talk page into its single threads.
 * @param {Array<String>} talk page content
 * @return {Object} o with
 * * {Array<Object>} o.threads: thread objects
 * * {int} o.lost: index of last line in root section (thus not belonging to any threads)
 */
function splitThreads(lines) {
  var threads = [];
  var thread = null;
  var level = 0, line, iLost = -1, section = 0;
  for (var i = 0; i < lines.length; ++i) {
    line = lines[i];
    level = getLevel(line);
    if (level > 0) {
      if (level == 2) {// new thread
        if (thread !== null) {// store current
          threads.push(thread);
        }
        thread = Thread(line.substring(level, line.length - level), ++section);
        AddinMooc_CONFIG.log(0, 'LOG_DIS_THREAD_SECTION', line, section);
      } else {
        // malformed: header at invalid level
        section += 1;
        thread.content.push(line);
      }
    } else {// thread content
      if (thread !== null) {
        thread.content.push(line);
      } else {// content not belonging to any threads
        iLost = i;
      }
    }
  }
  if (thread !== null) {// store thread finished by EOF
    threads.push(thread);
  }
  
  return {
    'threads': threads,
    'lost': iLost
  };
}

/*####################
  # UTILITIES
  # low-level helper functions
  ####################*/
 
/**
 * Repeats a string value a given number of times.
 * @param {String} value to repeat
 * @param {int} number of times to repeat the value
 * @return {String} value repeated the given number of times.
 */
function strrep(value, numRepeat) {
	return new Array(numRepeat + 1).join(value);
}

/**
 * Splits a text into its single lines.
 * @param {String} multiline text
 * @return {Array} single text lines
 */
function splitLines(text) {
  return text.split(/\r?\n/);
}

/**
 * Parses a Date object to a String.
 * @param {Date} Date to be parsed
 * @return {String} String representing the date passed (YYYY/MM/dd HH:mm)
 */
function dateToString(date) {
  return (date.getYear() + 1900) + "/" + date.getMonth() + "/" + date.getDate() + " " + date.getHours() + ":" + date.getMinutes();
}

/**
 * Calculates the header level of a wikitext line.
 * @param {String} wikitext line
 * @return {int} header level of the line passed, 0 if the line is no header
 */
function getLevel(line) {
  var sLevelStart = line.match('^=*');
  if (sLevelStart.length > 0 && sLevelStart[0]) {
    var sLevelEnd = line.match('=*$');
    if (sLevelEnd.length > 0 && sLevelEnd[0]) {
      return Math.min(sLevelStart[0].length, sLevelEnd[0].length);
    }
  }
  return 0;
}

/**
 * Converts month name to index.
 * @param {String} month name
 * @return {int} month index starting with 1; -1 if month name unknown
 * @see http://stackoverflow.com/questions/13566552/easiest-way-to-convert-month-name-to-month-number-in-js-jan-01
 */
function getMonthFromString(mon){
  // en: "August"
  // de: "Nov."
  var month = mon.replace('.', '');
  var d = Date.parse(month + "1, 2014");
  if (!isNaN(d)){
    return new Date(d).getMonth() + 1;
  }
  return -1;
}

/**
 * Calculates the post level of a talk page line.
 * @param {String} talk page line
 * @return {int} post level of the line passed (0: no post, n: reply level n)
 */
function getPostLevel(line) {
  var level = line.match('^:*');
  if (level.length > 0 && level[0]) {// post reply
    return level[0].length;
  }
  return 0;
}

/**
 * Calculates the length of the signature in a post.
 * @param {String} post content
 * @return {int} length of the signature; 0 if post is unsigned
 */
function getSignatureLength(content) {
  if (content.match(/\~\~\~\~$/) !== null) {
    if ((content.match(/\-\-\~\~\~\~$/) !== null)) {
      return 6;
    }
    return 4;
  }
  return 0;
}

/**
 * Returns the talk page title of a wiki page.
 * @param {String} title of the wiki page
 * @return {String} title of the talk page of the wiki page specified
 */
function getTalkPageTitle(pageTitle) {
  var iNamespace = pageTitle.indexOf(':');
  var iSlash = pageTitle.indexOf('/');
  var sNamespaceTalk = AddinMooc_CONFIG.get('MW_NAMESPACE_TALK');
  if (iNamespace == -1 || iNamespace > iSlash) {
    return sNamespaceTalk + ':' + pageTitle;
  }
  var namespace = pageTitle.substring(0, iNamespace);
  return namespace + ' ' + sNamespaceTalk.toLowerCase() + pageTitle.substring(iNamespace);
}

/**
 * Strips a post text from any unwanted characters.
 * 1.) manual intendation
 * 2.) additional thread titles
 * 3.) manual signature
 * 4.) leading/trailing whitespace
 * @param {String} post content
 * @return {String} stripped post content
 */
function stripPost(content) {
  var lines = splitLines(content);
  var line, postLevel, level;
  for (var i = 0; i < lines.length; ++i) {
    line = lines[i];
    // strip leading ':'
    postLevel = getPostLevel(line);
    if (postLevel > 0) {
      line = line.substring(postLevel);
    }
    // remove thread starts
    level = getLevel(line);
    if (level > 0) {
      line = line.substring(level, line.length - level);
    }
    lines[i] = line;
  }
  var post = lines.join('\n');
  // remove signature added manually
  var signatureLength = getSignatureLength(post);
  if (signatureLength > 0) {
    post = post.substring(0, post.length - signatureLength);
  }
  // remove leading/trailing whitespace
  return $.trim(post);
}

/**
 * Parses a wiki timestamp to a Date object.
 * @param {String} timestamp in wiki format
 * @return {Date} Date object representing the given timestamp; null if parsing failed
 */
function parseTimestamp(value) {
  var time = value.substring(0, 5);
  var timeParts = time.split(':');
  var day = value.substring(7);
  var dayParts = day.split(' ');
  var date = new Date();
  var month = getMonthFromString(dayParts[1]);
  // en: "13:05, 28 August 2014 (UTC)"
  // de: "10:20, 12. Nov. 2014 (CET)"
  if (month != -1) {
    day = dayParts[0].replace('.', '');
    date.setUTCDate(day);
    date.setUTCMonth(month);
    date.setUTCFullYear(dayParts[2]);
    date.setUTCHours(timeParts[0]);
    date.setUTCMinutes(timeParts[1]);
    return date;
  }
  return null;
}

//</nowiki>