SankakuAddon

Adds a few quality of life improvements on Sankaku Channel: 'Choose/Set Parent' modes, automatic image scaling, duplicate tagging/flagging, muting videos, speaker icons on loud videos, + - tag search buttons. Fully configurable using localStorage.

// ==UserScript==
// @name        SankakuAddon
// @namespace   SankakuAddon
// @description Adds a few quality of life improvements on Sankaku Channel: 'Choose/Set Parent' modes, automatic image scaling, duplicate tagging/flagging, muting videos, speaker icons on loud videos, + - tag search buttons. Fully configurable using localStorage.
// @include     http://chan.sankakucomplex.com/*
// @include     https://chan.sankakucomplex.com/*
// @include     http://idol.sankakucomplex.com/*
// @include     https://idol.sankakucomplex.com/*
// @run-at      document-start
// @version     0.98.96
// @grant       none
// ==/UserScript==


"use strict";
var version = "v0.98.96";

if (!String.prototype.startsWith) {
  String.prototype.startsWith = function(searchString, position) {
    return this.substr(position || 0, searchString.length) === searchString;
  };
}


/***************************/
/* configuration functions */
/***************************/

var default_config = {
  scroll_to_image:true,
  scale_image:true, //and video
  scale_only_downscale:false,
  scale_flash:false,
  scale_mode:0,
  scale_on_resize:false,
  scroll_to_image_center:true,
  video_pause:false,
  video_mute:true,
  video_controls:true,
  show_speaker_icon:true,
  show_animated_icon:true,
  setparent_deletepotentialduplicate:false,
  hide_headerlogo:false,
  tag_search_buttons:true,
  tag_menu:true,
  common_tags_json:"[ {\"name\":\"test tags\", \"tags\":[\"tag1 tag2\", [\"grouped_tag1 grouped_tag2\"], \"tag3 tag4\"] }, { \"tags\":[ \"next_line tag5 tag6\", [\"grouped_tag3 grouped_tag4\"] , \"tag7 tag8\"] }, {\"name\":\"another category\", \"tags\":[\"t1 t2 t3\"]} ]",
  sankaku_channel_dark_compatibility:false
};
var config_value_types = ["scale_mode", "common_tags_json"];


var use_local_storage = (typeof(Storage) !== "undefined");
var key_prefix = "config.";

var config = JSON.parse(JSON.stringify(default_config));
var dom_content_loaded = false;

function save_config(key, value) {
  if (!use_local_storage) {
    notice("addon: couldn't save setting \"" + key + " = " + value + "\" to local storage. check permissions.");
    return;
  }

  var cfg_key = key_prefix + key;
  localStorage.setItem(cfg_key, JSON.stringify(value));
}

function load_config() {
  for (var key in config) {
    if (config.hasOwnProperty(key)) {
      var value = config[key]; //default already loaded

      if (use_local_storage) {
        var cfg_key = key_prefix + key;
        var stored_value = localStorage.getItem(cfg_key);
        if (stored_value) value = JSON.parse(stored_value);
      }

      config_changed(key, value); //fire regardless
    }
  }
}

function local_storage_changed(e) {
  if (e.key.startsWith(key_prefix)) {
    var key = e.key.substring(key_prefix.length);
    var value = JSON.parse(e.newValue);

    config_changed(key, value);
  }
}

//event that is fired whenever a setting changes in the config dialog or the local storage
function config_changed(key, value) {
  config[key] = value;

  if (!dom_content_loaded) return; //UI hasn't loaded yet
  update_config_dialog_by_key(key);
  if (key === "hide_headerlogo") {
    update_headerlogo();
  } else if (key === "scale_on_resize") {
    if (value) add_scale_on_resize_listener();
    else       remove_scale_on_resize_listener();
  }
}

function reset_config() {
  //clear local storage
  if (use_local_storage) {
    for (var key in config) {
      if (config.hasOwnProperty(key)) {
        var cfg_key = key_prefix + key;
        localStorage.removeItem(cfg_key);
      }
    }
  }

  for (var key in config) {
    if (config.hasOwnProperty(key)) {
      config_changed(key, default_config[key]);
    }
  }
}

//calls f(cfg_elem, key, get_value) for each existing config element
function foreach_config_element(f) {
  for (var key in config) {
    if (config.hasOwnProperty(key)) {
      var cfg_key = key_prefix + key;
      var cfg_elem = document.getElementById(cfg_key);
      if (cfg_elem === null) continue;

      (function(cfg_elem) {
        if (config_value_types.indexOf(key) != -1) {
          f(cfg_elem, key, function() { return cfg_elem.value; });
        } else {
          f(cfg_elem, key, function() { return cfg_elem.checked; });
        }
      })(cfg_elem);
    }
  }
}

function update_config_dialog_by_key(key) {
  var cfg_key = key_prefix + key;
  var cfg_elem = document.getElementById(cfg_key);
  if (cfg_elem !== null) {
    if (config_value_types.indexOf(key) != -1) {
      cfg_elem.value   = config[key];
    } else {
      cfg_elem.checked = config[key];
    }
  }
}

function update_config_dialog() {
  for (var key in config) {
    if (config.hasOwnProperty(key)) {
      update_config_dialog_by_key(key);
    }
  }
}

function update_headerlogo() {
  hide_headerlogo(config.hide_headerlogo);
}

function show_config_dialog(bool) {
  document.getElementById("cfg_dialog").style.display = (bool ? "" : "none");
}


/********************/
/* helper functions */
/********************/

function get_scrollbar_width() { //from Stack Overflow
  var outer = document.createElement("DIV");
  outer.style.visibility = "hidden";
  outer.style.width = "100px";
  document.body.appendChild(outer);

  var widthNoScroll = outer.offsetWidth;

  outer.style.overflow = "scroll"; //force scrollbars

  var inner = document.createElement("DIV");
  inner.style.width = "100%";
  outer.appendChild(inner);

  var widthWithScroll = inner.offsetWidth;

  outer.parentNode.removeChild(outer);

  return widthNoScroll - widthWithScroll;
}

//helper function to modify nodes on creation
function register_observer(node_predicate, node_modifier) {
  var observer = new MutationObserver(function(mutations) {
    for (var i = 0; i < mutations.length; i++) {
      var added_nodes = mutations[i].addedNodes;
      for (var j = 0; j < added_nodes.length; j++) {
        var node = added_nodes[j];
        if (node_predicate(node)) {
          if (node_modifier(node, observer)) { //are we done?
            observer.disconnect();
            return;
          }
        }
      }
    }
  });
  observer.observe(document, {childList: true, subtree: true});
  return observer;
}


/*********************/
/* general functions */
/*********************/

var header_offset_height = null; //distance needed to scroll if headerlogo is hidden/shown


function hide_headerlogo(hide) {
  var headerlogo = document.getElementById("headerlogo");
  if (headerlogo === null) return;

  var visible = (headerlogo.style.display != "none");

  headerlogo.style.display = (hide ? "none" : "");

  //scroll if needed
  if (visible === !!hide) window.scrollBy(0, (visible && hide ? -1 : 1) * header_offset_height);
}

function add_config_dialog() {
  var cfg_dialog = document.createElement("div");
  cfg_dialog.id = "cfg_dialog";
  cfg_dialog.style.display = "none";
  cfg_dialog.style.border = "1px solid #DDD";
  cfg_dialog.style.top = "50%";
  cfg_dialog.style.transform = "translateY(-50%)";
  cfg_dialog.style.width = "450px";
  cfg_dialog.style.height = "450px";
  cfg_dialog.style.position = "fixed";
  cfg_dialog.style.left = "0";
  cfg_dialog.style.right = "0";
  cfg_dialog.style.margin = "0 auto";
  cfg_dialog.style.overflow = "auto";
  cfg_dialog.style.backgroundColor = "white";
  cfg_dialog.style.zIndex = "10002";

  cfg_dialog.innerHTML = ""
    + "<div style='display: table; position: absolute; height: 100%; width: 100%'>"
    + "<div style='display: table-cell; vertical-align: middle'>"
    + "<div style='padding: 8px; margin-left: auto; margin-right: auto;'>"
    +   "<b>Sankaku Addon "+version+"</b><br>"
    +   "<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid #DDD;'>"
    +   "<span>"
    +     "<input id='" + key_prefix + "scroll_to_image' type='checkbox'>"
    +     "<span>Scroll to image/video when opening post</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "scroll_to_image_center' type='checkbox'>"
    +     "<span>Scroll to center of image/video, else scroll to top</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "scale_image' type='checkbox'>"
    +     "<span>Scale image/video when opening post</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "scale_only_downscale' type='checkbox'>"
    +     "<span>Only downscale</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "scale_flash' type='checkbox'>"
    +     "<span>Also scale flash videos</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "scale_on_resize' type='checkbox'>"
    +     "<span title=\"This uses the 'scale image mode' setting, so it doesn't work well when using the manual scaling actions.\" style='cursor:help'>Scale image on window resize</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<span>Scale image/video mode: </span>"
    +     "<select id='" + key_prefix + "scale_mode'>"
    +       "<option value=0>Fit to window</option>"
    +       "<option value=1>Fit horizontally</option>"
    +       "<option value=2>Fit vertically</option>"
    +     "</select>"
    +     "<br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "video_pause' type='checkbox'>"
    +     "<span>Pause (non-flash) videos*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "video_mute' type='checkbox'>"
    +     "<span>Mute (non-flash) videos*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "video_controls' type='checkbox'>"
    +     "<span>Show video controls*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "tag_search_buttons' type='checkbox'>"
    +     "<span>Show + - tag search buttons*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "show_speaker_icon' type='checkbox'>"
    +     "<span>Show 🔊 icon on thumbnail if it has audio*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "show_animated_icon' type='checkbox'>"
    +     "<span>Show ⏩ icon on thumbnail if it is animated (🔊 overrides ⏩)*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "setparent_deletepotentialduplicate' type='checkbox'>"
    +     "<span>Delete potential_duplicate tag when using \"Set Parent\"</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "hide_headerlogo' type='checkbox'>"
    +     "<span>Hide header logo</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "sankaku_channel_dark_compatibility' type='checkbox'>"
    +     "<span>Galinoa's Sankaku Channel Dark compatibilty*</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "tag_menu' type='checkbox'>"
    +     "<span>Activate tag menu*:</span><br>"
    +   "</span>"
    +   "<span>"
    +     "<input id='" + key_prefix + "common_tags_json'>"
    +     "<span> Common tags list (JSON format)*</span><br>"
    +   "</span>"
    +   "<button id='config_close'>Close</button>"
    +   "<button id='config_reset'>Reset settings</button>"
    +   "<span>&nbsp;*requires a page reload.</span>"
    + "</div>"
    + "";
  document.body.appendChild(cfg_dialog);

  //add events
  document.getElementById("config_close").onclick = function() { show_config_dialog(false); return false; };
  document.getElementById("config_reset").onclick = function() { reset_config(); return false; };
  foreach_config_element(function(cfg_elem, key, get_value) {
    cfg_elem.addEventListener("change", function() {
      config_changed(key, get_value());
      save_config(key, get_value());
    });
  });
}

function add_config_button() {
  var navbar = document.getElementById("navbar");
  if (navbar === null) {
    notice("addon error: couldn't find \"navbar\" element! Config dialog disabled.");
    return;
  }

  navbar.style.whiteSpace = "nowrap"; //hack to fit config button

  var a = document.createElement("A");
  a.href = "#";
  a.onclick = function() { show_config_dialog(true); return false; };
  a.innerHTML = "<span style='font-size: 110%'>⚙</span> Addon config";
  a.style.fontSize = "120%";

  var li = document.createElement("LI");
  li.className = "lang-select"; //match style of top bar
  li.appendChild(a);
  navbar.appendChild(li);
}

function add_tag_search_buttons() {
  var tagsidebar = document.getElementById("tag-sidebar");
  if (tagsidebar === null) return;

  var items = tagsidebar.getElementsByTagName("LI");
  for (var i = 0; i < items.length; i++) {
    var taglink = items[i].getElementsByTagName("A")[0];
    var tagname = taglink.innerHTML.replace(/ /g, "_"); // " " -> "_" hopefully this is the only edgecase

    //generates onclick events
    var tag_search_button_func = function(tagname) {
      return function() {
        var search_field = document.getElementById("tags");

        var search_tags = search_field.value.trim().split(/\s+/);
        var tag_index = search_tags.indexOf(tagname);

        //add if missing, remove if existing
        if (tag_index == -1) {
          search_field.value = (search_field.value + " " + tagname).trim();
        } else {
          search_tags.splice(tag_index, 1);
          search_field.value = search_tags.join(" ");
        }

        //focus but don't scroll
        var x = window.scrollX, y = window.scrollY;
        search_field.focus();
        window.scrollTo(x, y);

        return false;
      };
    };

    var a = document.createElement("A");
    a.href = "#";
    a.innerHTML = "+";
    a.onclick = tag_search_button_func(tagname);

    items[i].insertBefore(a, taglink);
    items[i].insertBefore(document.createTextNode(" "), taglink);

    a = document.createElement("A");
    a.href = "#";
    a.innerHTML = "-";
    a.onclick = tag_search_button_func("-" + tagname);

    items[i].insertBefore(a, taglink);
    items[i].insertBefore(document.createTextNode(" "), taglink);
  }
}

function add_speaker_icons(root) {
  if (root === null) return;

  var elems = root.getElementsByTagName("SPAN");
  for (var i = 0; i < elems.length; i++) {
    if (elems[i].classList.contains("thumb")) {
      add_speaker_icon(elems[i]);
    }
  }
}

function add_speaker_icon(thumb_span) {
  var img = thumb_span.querySelector(".preview");
  if (img === null) return;
  var a = thumb_span.getElementsByTagName("A");
  if (a.length === 0) return;

  var icon = document.createElement("SPAN");
  var tags = img.title.trim().split(/\s+/);

  if (config.show_speaker_icon && (tags.indexOf("has_audio") != -1)) {
    icon.innerHTML = "🔊";
  } else if (config.show_animated_icon && (tags.indexOf("animated") != -1 || tags.indexOf("video") != -1)) {
    icon.innerHTML = "⏩";
  } else {
    return;
  }

  icon.className = "speaker_icon";
  icon.style.color = "#666";
  icon.style.position = "absolute";
  icon.style.top   = "2px"; //account for border
  icon.style.right = "2px";
  icon.style.fontSize = "200%";
  icon.style.textShadow = "-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white";
  icon.style.transform = "translateX(50%) translateY(-50%)";

  a[0].style.display = "inline-block"; //makes the element fit its content
  a[0].style.position = "relative";
  a[0].appendChild(icon);
}

function add_speaker_icons_observer(predicate) {
  if (config.show_speaker_icon || config.show_animated_icon) { //don't hog CPU when disabled, but requires page reload to activate
    //this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded
    register_observer(predicate, function (node, observer) {
      add_speaker_icons(node);
      return false; //listen forever
    });
  }
}

function configure_video(node) {
  if (node === null || node.nodeType !== Node.ELEMENT_NODE || node.tagName !== "VIDEO") return;

  if (config.video_pause)    node.pause();
  if (config.video_mute)     node.muted = true;
  if (config.video_controls) node.controls = true;
}


/***********************************************/
/* main page / visually similar page functions */
/***********************************************/

var added_mode_options = false;
function add_mode_options(mode_dropdown) {
  if (added_mode_options) return;
  added_mode_options = true;
  var option;
  if (mode_dropdown.options.namedItem("choose-parent") === null) {
    option = document.createElement("option");
    option.text = "Choose Parent";
    option.value = "choose-parent";
    mode_dropdown.add(option);
  }
  if (mode_dropdown.options.namedItem("set-parent") === null) {
    option = document.createElement("option");
    option.text = "Set Parent";
    option.value = "set-parent";
    mode_dropdown.add(option);
  }
}


/***********************/
/* post page functions */
/***********************/

var post_parent_id = null; //input elem
//original post/parent ids
var post_id = null;
var parent_id = null;
//original image size
var img_elem = null; //image or video
var non_img_elem = null, emb_elem = null; //flash or unknown
var img_is_flash;
var img_width = null;
var img_height = null;
var img_aspect_ratio = null;
var img_current_height = null; //store current height separately, because flash is a bitch
var resize_timer;
var tag_update_timer;
//set by find_actions_list()
var actions_ul = null;
var found_delete_action = false;

function add_tag_menu() {
  if (document.getElementById("post_tags") === null) return; //not logged in

  var tag_menu = document.createElement("DIV");
  tag_menu.id = "tag_menu";
  tag_menu.style.display = "none";
  tag_menu.style.border = "1px solid #DDD";
  tag_menu.style.width  = "100%";
  tag_menu.style.height = "30%"; //TODO variable height
  tag_menu.style.position = "fixed";
  tag_menu.style.bottom = "0";
  tag_menu.style.overflow = "auto";
  tag_menu.style.backgroundColor = "white";
  tag_menu.style.zIndex = "10001";
  document.body.appendChild(tag_menu);

  //the inner div ensures tag_menu_close button doesn't scroll with the content
  tag_menu.innerHTML = "<div style='width: 100%; height: 100%; overflow: auto;'>" + "<span id='common_tags'></span>" + "<br>current tags:<br>" + "<span id='current_tags'></span></div>";

  var generate_tag_menu_button = function(id, text) {
    var button = document.createElement("DIV");
    button.id = id;
    button.style.border = "1px solid #DDD";
    button.style.width  = "24px";
    button.style.height = "24px";
    button.style.position = "absolute";
    button.style.backgroundColor = "#EEE";
    button.innerHTML = "<span style='position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);'>" + text + "</span>";
    button.style.zIndex = "10001";
    return button;
  };

  var tag_menu_close = generate_tag_menu_button("tag_menu_close", "X");
  tag_menu_close.style.right = "0";
  tag_menu_close.style.top = "0";
  tag_menu_close.onclick = function() { show_tag_menu(false); return false; };
  tag_menu.appendChild(tag_menu_close);

  var tag_menu_open = generate_tag_menu_button("tag_menu_open", "«");
  tag_menu_open.style.position = "fixed";
  tag_menu_open.style.right = "0";
  tag_menu_open.style.bottom = "0";
  tag_menu_open.onclick = function() { show_tag_menu(true); update_tag_menu(); return false; };
  document.body.appendChild(tag_menu_open);
}

function update_tag_menu() {
  if (document.getElementById("post_tags") === null) return; //not logged in

  var common_tags_elem  = document.getElementById("common_tags");
  var current_tags_elem = document.getElementById("current_tags");

  //tag menu disabled
  if (common_tags_elem === null || current_tags_elem === null)
    return;

  var generate_tag_button = function(tag) {
    var a = document.createElement("A");
    a.href = "#";
    a.style.paddingLeft  = "5px";
    a.style.paddingRight = "5px";
    a.style.borderStyle = "solid";
    a.style.borderWidth = "1px";
    a.className = (tag_is_present(tag) ? "" : "tag_nonexistent");

    a.onclick = function(tag, a) {
      return function() {
        if (tag_is_present(tag)) {
          remove_tag(tag);
          a.className = "tag_nonexistent";
        } else {
          add_tag(tag);
          a.className = "";
        }
        return false;
      };
    }(tag, a);
    a.innerHTML = tag;
    return a;
  };

  var generate_li = function(padding, margin) {
    var li = document.createElement("LI");
    li.style.paddingTop    = padding;
    li.style.paddingBottom = padding;
    li.style.marginLeft  = margin;
    li.style.marginRight = margin;
    li.style.float = "left";
    return li;
  };

  var generate_tag_button_li = function(tag) {
    var li = generate_li("3px", "3px");
    li.appendChild(generate_tag_button(tag));
    return li;
  };


  var create_tag_list = function() {
    var ul = document.createElement("UL");
    ul.style.listStyleType = "none";
    ul.style.margin  = "0";
    ul.style.padding = "0";
    ul.style.display = "inline-block";
    return ul;
  };

  //generate tag button list for current tags
  var current_tags_ul = create_tag_list();
  var current_tags = get_tags_array();
  for (var i = 0; i < current_tags.length; i++) {
    current_tags_ul.appendChild(generate_tag_button_li(current_tags[i]));
  }

  //replace current list with new one
  while (current_tags_elem.hasChildNodes()) {
    current_tags_elem.removeChild(current_tags_elem.lastChild);
  }
  current_tags_elem.appendChild(current_tags_ul);

  //now add common tags
  //common_tags_json should be an array of objects with a string "name" field and an array "tags" field,
  //where the "tags" array can contain strings (space separated tags) or arrays containing one string (representing a group)
  //ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ]
  var tag_data = JSON.parse(config.common_tags_json);

  if (!Array.isArray(tag_data)) {
    notice("addon error: \"common tags\" needs to be an array of objects");
    return;
  }

  while (common_tags_elem.hasChildNodes()) {
    common_tags_elem.removeChild(common_tags_elem.lastChild);
  }

  for (var k = 0; k < tag_data.length; k++) {
    var list_ul = create_tag_list();
    var list_name = tag_data[k].name;
    var list_tags = tag_data[k].tags;

    if (!Array.isArray(list_tags)) {
      notice("addon error: a \"common tags\" object needs to have a \"tags\" array");
      return;
    }


    for (var i = 0; i < list_tags.length; i++) {
      var is_group = Array.isArray(list_tags[i]);
      var tags = (is_group ? list_tags[i][0] : list_tags[i]).trim().split(/\s+/);

      if (is_group) {
        //<ul><li><div><li (button)>
        var div = document.createElement("DIV");
        div.style.display = "inline-block";
        div.style.marginLeft = "3px";
        div.style.marginRight = "3px";
        div.style.backgroundColor = "#EEE";
        for (var j = 0; j < tags.length; j++) {
          div.appendChild(generate_tag_button_li(tags[j]));
        }
        var li = generate_li("0", "0");
        li.appendChild(div);
        list_ul.appendChild(li);
      } else {
        //<ul><li (button)>
        for (var j = 0; j < tags.length; j++) {
          list_ul.appendChild(generate_tag_button_li(tags[j]));
        }
      }
    }

    var span = document.createElement("SPAN");
    span.innerHTML = (k != 0 ? "<br>" : "") + (list_name ? list_name + ":<br>" : "");
    common_tags_elem.appendChild(span);
    common_tags_elem.appendChild(list_ul);
  }

}

function show_tag_menu(bool) {
  document.getElementById("tag_menu").style.display = (bool ? "" : "none");
  document.getElementById("tag_menu_open").style.display = (!bool ? "" : "none");
}

function add_tag_menu_change_listener() {
  var post_tags_area = document.getElementById("post_tags");
  if (post_tags_area !== null) {
    post_tags_area.addEventListener("change", function() {
      clearTimeout(tag_update_timer);
      tag_update_timer = setTimeout(function() {
        updated_tags();
      }, 500);
    });
  }
}

function find_actions_list() {
  var li = document.getElementById("add-to-pool");
  if (li === null) return;

  actions_ul = li.parentElement;

  var action_links = actions_ul.getElementsByTagName("A");
  for (var i = 0; i < action_links.length; i++) {
    if (action_links[i].innerHTML == "Delete") {
      found_delete_action = true;
      break;
    }
  }
}

function add_addon_actions() {
  if (actions_ul === null) {
    notice("addon error: couldn't find actions list! Addon actions disabled.");
    return;
  }

  var separator = document.createElement("H5");
  separator.innerHTML = "Addon actions";
  var newli = document.createElement("LI");
  newli.appendChild(separator);
  actions_ul.appendChild(newli);

  var add_action = function(func, name, id) {
    var a = document.createElement("A");
    a.href = "#";
    a.onclick = function() {func(); return false;};
    a.innerHTML = name;

    var newli = document.createElement("LI");
    newli.id = id;
    newli.appendChild(a);
    actions_ul.appendChild(newli);
  };

  add_action(function() { scale_image( 0, true); scroll_to_image(config.scroll_to_image_center); }, "Fit image",              "scale-image-fit");
  add_action(function() { scale_image( 1, true); scroll_to_image(config.scroll_to_image_center); }, "Fit image (Horizontal)", "scale-image-hor");
  add_action(function() { scale_image( 2, true); scroll_to_image(config.scroll_to_image_center); }, "Fit image (Vertical)",   "scale-image-ver");
  add_action(function() { scale_image(-1, true); scroll_to_image(config.scroll_to_image_center); }, "Reset image size",       "reset-image");

  if (parent_id === null) return; //not logged in

  if (post_id === null) {
    notice("addon error: couldn't find \"hidden_post_id\" element! Flag duplicate feature disabled.");
    return;
  }

  add_action(function() { flag_duplicate(post_id, "");                       }, "Flag duplicate",              "flag-duplicate");
  add_action(function() { flag_duplicate(post_id, ", visually identical");   }, "Flag duplicate (identical)",  "flag-duplicate-identical");
  add_action(function() { flag_duplicate(post_id, " with worse quality");    }, "Flag duplicate (quality)",    "flag-duplicate-quality");
  add_action(function() { flag_duplicate(post_id, " with worse resolution"); }, "Flag duplicate (resolution)", "flag-duplicate-resolution");
}

function add_tag_buttons() {
  var edit_form = document.getElementById("edit-form");
  if (edit_form === null) return; //not logged in

  var button_place = edit_form.children[1].children[0].children[0].children[0];

  button_place.setAttribute("nowrap", "nowrap"); //hack to keep buttons from wrapping (not HTML5 conform, should use CSS)

  var el = document.createElement("BUTTON");
  el.id = "clear_parent_id_button";
  el.style.margin = "0 3px 0 6px";
  el.innerHTML = "Clear";
  el.onclick = function() { post_parent_id.clear(); return false;};
  post_parent_id.parentNode.appendChild(el);

  el = document.createElement("BUTTON");
  el.id = "reset_parent_id_button";
  el.style.margin = "0 3px";
  el.innerHTML = "Reset";
  el.onclick = function() { reset_parent_id(); return false;};
  post_parent_id.parentNode.appendChild(el);

  el = document.createElement("BUTTON");
  el.id = "tag_reset_button";
  el.style.margin = "0 3px 0 6px";
  el.innerHTML = "Reset";
  el.onclick = function() { reset_tags(); return false; };
  button_place.appendChild(el);

  el = document.createElement("BUTTON");
  el.id = "tag_dup_button";
  el.style.margin = "0 3px";
  button_place.appendChild(el);

  el = document.createElement("BUTTON");
  el.id = "tag_var_button";
  el.style.margin = "0 3px";
  button_place.appendChild(el);

  el = document.createElement("BUTTON");
  el.id = "tag_pot_button";
  el.style.margin = "0 3px";
  button_place.appendChild(el);
}

function update_tag_buttons() {
  var taglist = document.getElementById("post_tags");
  var dup_button = document.getElementById("tag_dup_button");
  var var_button = document.getElementById("tag_var_button");
  var pot_button = document.getElementById("tag_pot_button");

  if (taglist === null || dup_button === null || var_button === null || pot_button === null)
    return;

  var tags = get_tags_array();

  if (tags.indexOf("duplicate") == -1) {
    dup_button.onclick = function() {add_tag("duplicate"); return false;};
    dup_button.innerHTML = "Tag duplicate";
  } else {
    dup_button.onclick = function() {remove_tag("duplicate"); return false;};
    dup_button.innerHTML = "Untag duplicate";
  }

  if (tags.indexOf("legitimate_variation") == -1) {
    var_button.onclick = function() {add_tag("legitimate_variation"); return false;};
    var_button.innerHTML = "Tag legitimate_variation";
  } else {
    var_button.onclick = function() {remove_tag("legitimate_variation"); return false;};
    var_button.innerHTML = "Untag legitimate_variation";
  }

  pot_button.innerHTML = "Untag potential_duplicate";
  if (tags.indexOf("potential_duplicate") == -1) {
    pot_button.disabled = true;
  } else {
    pot_button.onclick = function() {remove_tag("potential_duplicate"); return false;};
    pot_button.disabled = false;
  }
}

function reset_parent_id() {
  post_parent_id.value = parent_id;
}

function get_old_tags_array() {
  return document.getElementById("post_old_tags").value.trim().split(/\s+/);
}

function get_tags_array() {
  return document.getElementById("post_tags").value.trim().split(/\s+/);
}

function add_tag(tag) {
  var tags = get_tags_array();

  if ((tag == "duplicate" && tags.indexOf("legitimate_variation") != -1) || (tag == "legitimate_variation" && tags.indexOf("duplicate") != -1)) {
    notice("addon: cannot tag as duplicate and legitimate_variation at the same time.");
    return;
  }

  if (tags.indexOf(tag) != -1) {
    notice("addon: tag already present.");
    return;
  }

  document.getElementById("post_tags").value += " " + tag;

  updated_tags();
}

function remove_tag(tag) {
  var tags = get_tags_array();

  for (var i = 0; i < tags.length; i++) {
    if (tags[i] == tag) {
      tags[i] = "";
    }
  }

  document.getElementById("post_tags").value = tags.join(" ").trim();

  updated_tags();
}

function tag_is_present(tag) {
   return get_tags_array().indexOf(tag) != -1;
}

function reset_tags() {
  document.getElementById("post_tags").value = document.getElementById("post_old_tags").value;
  updated_tags();
}

//event that gets called whenever post_tags changes
function updated_tags() {
  update_tag_buttons();
  update_tag_menu();
}



//flag option with default text
function flag_duplicate(id, reason_suffix) {
  if (parent_id === null) {
    notice("addon: user not logged in");
    return false;
  }

  var current_parent_id = post_parent_id.value;
  if (current_parent_id != parent_id) {
    notice("addon: parent id was changed but not saved!");
    return false;
  }

  if (!current_parent_id || current_parent_id.length === 0) {
    notice("addon: no parent id set!");
    return false;
  }

  var tags = get_tags_array();
  var old_tags = get_old_tags_array();
  if (tags.indexOf("duplicate") != -1 && old_tags.indexOf("duplicate") == -1) {
    notice("addon: duplicate tag set but not saved!");
    return false;
  }
  if (old_tags.indexOf("duplicate") == -1) {
    notice("addon: not tagged as duplicate!");
    return false;
  }

  if (old_tags.indexOf("legitimate_variation") != -1) {
    notice("addon: tagged as legitimate_variation, are you sure it is a duplicate?");
    return false;
  }

  var reason = prompt("Why should this post be reconsidered for moderation?", "duplicate of " + parent_id + reason_suffix);
  if (reason === null) {
    return false;
  }
  new Ajax.Request("/post/flag.json", {
    parameters: {
      "id": id,
      "reason": reason
    },
    onComplete: function(response) {
      var resp = response.responseJSON;
      if (resp.success) {
        notice("Post was resent to moderation queue");
      } else {
        notice("Error: " + resp.reason);
      }
    }
  });
}


function read_image_data() { //TODO should probably be OOP
  var img;
try {
  img = document.getElementById("image"); //image or video
  if (img !== null) {
    img_elem = img;
    img_is_flash = false;

    //workaround for Galinoa's Sankaku Channel Dark: don't read .width/.height attributes but read "Details"
    var res = null;
    var lowres = document.getElementById("lowres");
    if (lowres !== null) {
      res = lowres.innerHTML.split("x"); //parse "<width>x<height>"
    } else {
      var highres = document.getElementById("highres");
      if (highres !== null) {
        res = highres.innerHTML.split(" ")[0].split("x"); //parse "<width>x<height> (<file size>)"
      }
    }

    if (res === null) {
      notice("addon error: image/video resolution not in \"Details\"?! Disabled scaling.");
      return;
    }

    if (img_width  === null) img_width  = res[0];
    if (img_height === null) img_height = res[1];
  } else {
    non_img_elem = document.getElementById("non-image-content");
    img_elem = non_img_elem.getElementsByTagName("OBJECT")[0];
    emb_elem = non_img_elem.getElementsByTagName("EMBED")[0];
    img_is_flash = (img_elem !== null && emb_elem !== null);

    img = img_elem; //object contains width/height

    //save original image size
    if (img_width  === null) img_width  = img.width;
    if (img_height === null) img_height = img.height;
  }

  img_aspect_ratio = img_width / img_height;
  img_current_height = img_height;
} catch(error) {
  alert("addon error: reading image data failed! please report this error to the author: " + img + " (" + img_width + ", " + img_height + "), error " + error);
}
}

//stretch image/video/flash, requires data from read_image_data()
function scale_image(mode, always_scale) {
  var img_rect_w, img_rect_h;
  var new_width, new_height;

try {
  mode = Number(mode);
  if (isNaN(mode)) {
    notice("addon error: scaling mode wasn't a number?");
    return;
  }

  if (img_width === null || img_height === null) //read_image_data() failed
    return;

  if (!always_scale && (!config.scale_flash && img_is_flash))
    return;

  //reset image size
  if (mode === -1) {
    if (!img_is_flash) {
      img_elem.style.width  = null;
      img_elem.style.height = null;
    } else {
      img_elem.width  = img_width;
      img_elem.height = img_height;
      emb_elem.width  = img_width;
      emb_elem.height = img_height;
    }
    img_current_height = img_height;

    //workaround for Galinoa's Sankaku Channel Dark
    if (config.sankaku_channel_dark_compatibility) {
      img_elem.style.paddingLeft = "";
      note_fix();
    }

    return;
  }

  var left_side;

  //workaround for Galinoa's Sankaku Channel Dark
  //problem: seems to only work for bigger windows
  if (config.sankaku_channel_dark_compatibility) {
    var sidebar = document.getElementsByClassName("sidebar")[0];
    left_side = (sidebar.getBoundingClientRect().right + 12);
    img_elem.style.paddingLeft = left_side + "px"; //don't hide behind sidebar
  } else {
    left_side = img_elem.getBoundingClientRect().left;
  }

  //target rect
  img_rect_w = Math.max(window.innerWidth - left_side - get_scrollbar_width() - 1, 1);
  img_rect_h = Math.max(window.innerHeight - 1, 1);
  var img_rect_aspect_ratio = img_rect_w / img_rect_h;

  //fit into window
  if (mode === 0) {
    mode = (img_aspect_ratio > img_rect_aspect_ratio ? 1 : 2);
  }

  //horizontal
  if (mode === 1) {
    new_width  = Math.floor(img_rect_w);
    new_height = Math.floor(img_rect_w / img_aspect_ratio);
  //vertical
  } else if (mode === 2) {
    new_width  = Math.floor(img_rect_h * img_aspect_ratio);
    new_height = Math.floor(img_rect_h);
  }

  if (!always_scale && (config.scale_only_downscale && (new_width > img_width || new_height > img_height)))
    return;


  var set_dimensions = function(obj, new_width, new_height) {
    obj.width  = new_width  + "px";
    obj.height = new_height + "px";
  };

  if (img_is_flash) {
    set_dimensions(img_elem, new_width, new_height);
    set_dimensions(emb_elem, new_width, new_height);
  } else {
    set_dimensions(img_elem.style, new_width, new_height);
  }

  img_current_height = new_height;
} catch(error) {
  alert("addon error: scaling failed! please report this error to the author: img rect (" + img_width + ", " + img_height + ") to rect (" + img_rect_w + ", " + img_rect_h + "), new size (" + new_width + ", " + new_height + "), exception " + error);
}
}

function scale_on_resize_helper() {
  clearTimeout(resize_timer);
  resize_timer = setTimeout(function() {
    if (config.scale_on_resize) scale_image(config.scale_mode, false);
  }, 100);
}

function add_scale_on_resize_listener() {
  window.addEventListener("resize", scale_on_resize_helper);
}

function remove_scale_on_resize_listener() {
  window.removeEventListener("resize", scale_on_resize_helper);
}

function scroll_to_image(to_center) {
  var absolute_img_top = (img_is_flash ? non_img_elem : img_elem).getBoundingClientRect().top + window.pageYOffset;
  if (to_center) {
    var top_of_centered_rect = absolute_img_top - (window.innerHeight - img_current_height) / 2;
    window.scrollTo(0, top_of_centered_rect);
  } else {
    window.scrollTo(0, absolute_img_top);
  }
}

//simple note fix for Galinoa's Sankaku Channel Dark (only for default image size)
function note_fix() {
  var note_container = document.getElementById("note-container");
  if (note_container !== null) {
    note_container.style.marginLeft = ((window.innerWidth - img_elem.clientWidth) / 2 - 8) + "px";
  }
}


/******************/
/* document-start */
/******************/

load_config();

/*************************************/
/* main page / visually similar page */
/*************************************/

if (location.pathname === "/" || location.pathname.startsWith("/post/similar")) {

  //try to add new modes right after the "Apply tag script" mode is added to prevent it being reset to "View posts" on page reloads
  //it's possible we are too late to observe its construction, so look for it afterwards immediately
  var observer = register_observer(function(node) {
    return (node.value === "apply-tag-script");
  }, function(node, observer) {
    observer.disconnect();
    add_mode_options(node.parentNode);
    return false;
  });

  var dropdown = document.getElementById("mode");
  if (dropdown !== null) {
    var children = dropdown.childNodes;
    for (var i = 0; i < children.length; i++) {
      if (children[i].value === "apply-tag-script") { //it's already there
        observer.disconnect(); //stop looking for it
        add_mode_options(dropdown);
      }
    }
  }

  //add thumbnail icons for dynamically loaded posts (from auto paging)
  if (config.show_speaker_icon || config.show_animated_icon) {
    add_speaker_icons_observer(function (node) { return (node.classList != null && node.classList.contains("content-page")); });
  }


/*************/
/* post page */
/*************/

} else if (location.pathname.startsWith("/post/show/")) {

  //mute/pause videos
  var observer = register_observer(function(node) {return node.id === "image"; }, function(node, observer) { configure_video(node); return true; });
  var video = document.getElementById("image");
  if (video !== null) {
    observer.disconnect();
    configure_video(video);
  }

  add_speaker_icons_observer(function (node) { return (node.id === "recommendations"); });

/*************/
/* user page */
/*************/

} else if (location.pathname.startsWith("/user/show/")) {

  add_speaker_icons_observer(function (node) { return (node.id === "recommendations"); });

}




/******************/
/* content-loaded */
/******************/

document.addEventListener("DOMContentLoaded", function() {
  dom_content_loaded = true;

  //sitefix for flagged posts not always showing red border
  //problem: "flagged" style is defined before "has-parent" and "has-children" CSS styles, so these two take priority
  //fix: just add another copy of the "flagged" style at the end
  var sheet = document.createElement("style");
  sheet.innerHTML  = "img.has-children {padding:0px;border:2px solid #A7DF38;} img.has-parent {padding:0px;border:2px solid #CCCC00;} img.flagged {padding: 0px; border: 2px solid #F00;}";
  sheet.innerHTML += " a.tag_nonexistent { color: #D00; }"; //custom style for tag menu
  document.body.appendChild(sheet);

  var headerlogo = document.getElementById("headerlogo");
  if (headerlogo !== null) {
    header_offset_height = headerlogo.offsetHeight;
    update_headerlogo();
  }

  add_config_dialog();
  add_config_button();
  update_config_dialog();

  //listen for config changes in other windows
  window.addEventListener("storage", local_storage_changed);

  if (config.show_speaker_icon || config.show_animated_icon) add_speaker_icons(document);


  /*************************************/
  /* main page / visually similar page */
  /*************************************/

  if (location.pathname === "/" || location.pathname.startsWith("/post/similar")) {

    if (config.tag_search_buttons) add_tag_search_buttons();

    if (!PostModeMenu.old_change) PostModeMenu.old_change = PostModeMenu.change;
    if (!PostModeMenu.old_click)  PostModeMenu.old_click  = PostModeMenu.click;

    //add change events
    PostModeMenu.change = function() {
      var s = $F("mode");

      PostModeMenu.old_change();

      if (s== "remove-fav") {
        document.body.setStyle({
          backgroundColor: "#FEA" //slightly more orange
        });
      } else if (s == "apply-tag-script") {
        document.body.setStyle({
          backgroundColor: "#FDF" //weaken color intensity
        });
      } else if (s == "approve") {
        document.body.setStyle({
          backgroundColor: "#DEF" //weaken color intensity
        });
		} else if (s == "choose-parent") {
        document.body.setStyle({
          backgroundColor: "#FFD"
        });
      } else if (s == "set-parent") {
        if (Cookie.get("chosen-parent") === null) {
          notice("addon: Choose parent first!");
          $("mode").value = "choose-parent";
          PostModeMenu.change();
        } else {
          document.body.setStyle({
            backgroundColor: "#DFF"
          });
        }
      }
    };

    //add click events
    PostModeMenu.click = function(post_id) {

      if (PostModeMenu.old_click(post_id)) {
        return true;
      }

      var s = $("mode");

      if (s.value == "choose-parent") {
        Cookie.put("chosen-parent", post_id);
        $("mode").value = "set-parent";
        PostModeMenu.change();
      } else if (s.value == "set-parent") {
        var parent_id = Cookie.get("chosen-parent");
        TagScript.run(post_id, "parent:" + parent_id + (config.setparent_deletepotentialduplicate ? " -potential_duplicate" : ""));
      }
      return false;
    };

    PostModeMenu.change(); //guarantee that 'mode' variable correctly changes to new modes when loading page

    document.addEventListener("keydown", function(e) {
      var tag = e.target.tagName.toLowerCase();
      if (tag == "input" || tag == "textarea" || tag == "select") return;
      var mode = document.getElementById("mode");
      if (mode === null) return;

      if (e.ctrlKey || e.altKey || e.shiftKey) return;

      switch (e.key) {
        case "v":
          mode.value = "set-parent";
          break;
        case "c":
          mode.value = "choose-parent";
          break;
        case "q":
          mode.value = "rating-q";
          break;
        case "s":
          mode.value = "rating-s";
          break;
        case "e":
          mode.value = "rating-e";
          break;
      }

      PostModeMenu.change();
    }, true);


  /*************/
  /* post page */
  /*************/

  } else if (location.pathname.startsWith("/post/show/")) {

    var hidden_post_id_el = document.getElementById("hidden_post_id");
    if (hidden_post_id_el !== null) {
      post_id = hidden_post_id_el.innerHTML;
    }

    post_parent_id = document.getElementById("post_parent_id");
    if (post_parent_id !== null) {
      parent_id = post_parent_id.value;
    }

    find_actions_list();
    add_addon_actions();
    add_tag_buttons();
    if (config.tag_menu) add_tag_menu();
    updated_tags();
    if (config.tag_search_buttons) add_tag_search_buttons();

    read_image_data();
    if (config.scale_image) scale_image(config.scale_mode, false);
    if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center);
    if (config.sankaku_channel_dark_compatibility) note_fix();

    document.addEventListener("keydown", function(e) {
      var tag = e.target.tagName.toLowerCase();
      if (tag == "input" || tag == "textarea" || tag == "select") return;

      if (e.ctrlKey || e.altKey || e.shiftKey) return;

      switch (e.key) {
        case "r": //r(eset)
          scale_image(-1, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case "f": //f(it)
          scale_image(0, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case "g": //g
          scale_image(1, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case "h": //h
          scale_image(2, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case "s": //s(imilar)
          if (post_id === null) {
            notice("addon error: couldn't find \"hidden_post_id\" element!")
          } else {
            window.open("/post/similar?id=" + post_id);
          }
          break;
        case "d": //d(elete)
          if (post_id === null) {
            notice("addon error: couldn't find \"hidden_post_id\" element!")
          } else if (!found_delete_action) {
            notice("addon error: Delete action not found, no permission?");
          } else {
            window.open("/post/delete/" + post_id);
          }
          break;
      }
    }, true);

    if (config.scale_on_resize) add_scale_on_resize_listener();

    add_tag_menu_change_listener();


  /**************/
  /* pool index */
  /**************/

  } else if (location.pathname.startsWith("/pool/index")) {

    //sitefix to show pool links even if missing english translation (they could not be clicked on otherwise)
    var pool_entries = document.getElementById("pool-index").getElementsByTagName("TABLE")[0].getElementsByTagName("TBODY")[0].getElementsByTagName("TR");

    for (var i = 0; i < pool_entries.length; i++) {
      var pool_name = pool_entries[i].getElementsByTagName("TD")[0].getElementsByTagName("A")[0];
      if (pool_name.innerHTML.trim().length == 0) pool_name.innerHTML = "&lt;missing English translation&gt;";
    }

  } else if (location.pathname.startsWith("/wiki/show")) {
    var h2 = document.getElementsByClassName("title")[0];
    var wiki_edit_link = document.createElement("A");
    var tag = new URL(window.location.href).searchParams.get("title");
    wiki_edit_link.href = "/tag/edit?name=" + tag;
    wiki_edit_link.innerHTML = "⚙";
    wiki_edit_link.title = "Edit Tag";
    h2.appendChild(wiki_edit_link);
  }

}, false);