SankakuAddon

Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.

Version au 26/02/2022. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name        SankakuAddon
// @namespace   SankakuAddon
// @description Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.
// @author      sanchan
// @version     0.99.57
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZHPKwRhGMc/dolYbeIgOewBOeyWKDlqFZflsFZZXGZmZ3fVzphmZpNclYuDchAXvw7+A67KlVKKlOTgL/DrIo3n3VUr8U7vPJ++7/t9et/vC6FUybC8+gGwbN9NTyRjs9m5WOMTETpoox00w3Mmp8cz/Dveb6hT9Tqhev2/78/RkjM9A+qahIcNx/WFR4VTy76jeEO4wyhqOeF94bgrBxS+ULpe5UfFhSq/KnYz6TEIqZ6xwg/Wf7BRdC3hfuEeq1Q2vs+jbhIx7ZlpqV0yu/FIM0GSGDplFinhk5BqS2Z/+wYqvimWxGPI32EFVxwFiuKNi1qWrqbUvOimfCVWVO6/8/TyQ4PV7pEkNDwEwUsvNG7B52YQfBwEwechhO/hzK75lySnkTfRN2tazx5E1+DkvKbp23C6Dp13juZqFSksM5TPw/MxtGah/Qqa56tZfa9zdAuZVXmiS9jZhT7ZH134ArhcZ+m/WStSAAAACXBIWXMAAAsSAAALEgHS3X78AAAAeElEQVQ4y2NgoCX4Xyb7H4TxqWHCo7keG5toA4CgAQebsAHYbMTlCiYibMfrCiZibcIlx0SE3/GGBRM+Gxi7HjeCMD41TESGPE5XMOGzHRsbnxcIxbsDDAMts4cbjmR7A4kpvQHkMiZCKY1QSmXBZTKedNBA1RwLAFCeNCTVhz2FAAAAAElFTkSuQmCC
// @match       https://chan.sankakucomplex.com/*
// @match       https://idol.sankakucomplex.com/*
// @run-at      document-start
// @noframes
// @grant       GM.registerMenuCommand
// @grant       GM_registerMenuCommand
// @grant       GM.addStyle
// @grant       GM.openInTab
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// @grant       GM.addValueChangeListener
// @grant       GM_addValueChangeListener
// @grant       GM.setClipboard
// @grant       unsafeWindow
// ==/UserScript==

(async function(unsafeWindow) {
  'use strict';

  const VERSION = 'v0.99.57';

  // based on the Tag Checklist in the wiki
  const DEFAULT_TAGLIST =
`[
  {
    "name": "People & Gender",
    "tags": [
      [
        ["female female_only 1girl 2girls 3girls 4girls 5girls 6+girls"],
        ["male male_only 1boy 2boys 3boys 4boys 5boys 6+boys"],
        ["futanari futanari_only 1_futanari 2_futanari 3_futanari 4_futanari 5_futanari 6+_futanari"],
        ["newhalf newhalf_only 1_newhalf 2_newhalf 3_newhalf 4_newhalf 5_newhalf 6+_newhalf"]
      ],
      "no_humans"
    ]
  },
  {
    "name": "Young Age",
    "tags": [
      "child loli shota toddlercon young"
    ]
  },
  {
    "name": "Androgynous",
    "tags": [
      "androgynous crossdressing genderswap trap reverse_trap"
    ]
  },
  {
    "name": "Group",
    "tags": [
      "solo duo trio quartet quintet sextet group"
    ]
  },
  {
    "name": "Relationship",
    "tags": [
      "couple siblings sisters brothers brother_and_sister twins triplets"
    ]
  },
  {
    "name": "Who/Other",
    "tags": [
      "anthropomorphization multiple_persona elf mecha monster monster_girl magical_girl fairy"
    ]
  },
  {
    "name": "Face",
    "tags": [
      "face ears eyes nose lips teeth facial_mark facial_hair beard"
    ]
  },
  {
    "name": "Upper Body",
    "tags": [
      "arms armpits armpit_crease armpit_peek back bare_shoulders breasts bust clavicle midriff navel stomach hands fingers"
    ]
  },
  {
    "name": "Lower Body",
    "tags": [
      "anus ass mound_of_venus vagina penis thighs knees feet barefoot legs bare_legs bare_thighs zettai_ryouiki toes"
    ]
  },
  {
    "name": "Breasts",
    "tags": [
      "cleavage breasts nipples areolae puffy_areolae areola_slip breasts_out_of_clothes breasts_apart underboob sideboob"
    ]
  },
  {
    "name": "Breast Size",
    "tags": [
      "pettanko small_breasts medium_breasts large_breasts huge_breasts gigantic_breasts alternate_bust_size"
    ]
  },
  {
    "name": "Skin Color",
    "tags": [
      "pale_skin dark_skin tanned shiny_skin albino",
      ["red_skin orange_skin yellow_skin green_skin blue_skin purple_skin pink_skin white_skin grey_skin black_skin"]
    ]
  },
  {
    "name": "Hairstyle",
    "tags": [
      "ahoge bangs blunt_bangs bob_cut double_bun drill_hair hair_over_one_eye peek-a-boo_bang ponytail side_pony_tail single_braid spiky_hair twinbraids twintails alternate_hairstyle two_side_up"
    ]
  },
  {
    "name": "Hair Length",
    "tags": [
      "very_short_hair short_hair medium_hair long_hair very_long_hair absurdly_long_hair"
    ]
  },
  {
    "name": "Hair/Eye Color",
    "tags": [
      [
        ["blonde      black_hair blue_hair brown_hair green_hair grey_hair orange_hair pink_hair purple_hair red_hair silver_hair white_hair"],
        ["golden_eyes black_eyes blue_eyes brown_eyes green_eyes grey_eyes orange_eyes pink_eyes purple_eyes red_eyes silver_eyes white_eyes"]
      ]
    ]
  },
  {
    "name": "Animal Parts",
    "tags": [
      "animal_ears bat_wings bunny_ears cat_tail wolf_ears fang horns kitsunemimi nekomimi tail inumimi wings angel_wings"
    ]
  },
  {
    "name": "Look/Other",
    "tags": [
      "chibi mole muscle pointed_ears pregnant scar curvy animal_ear_fluff fluffy_tail"
    ]
  },
  {
    "name": "Swimwear",
    "tags": [
      "bikini one-piece_swimsuit swimsuit competition_swimsuit sukumizu"
    ]
  },
  {
    "name": "Facewear",
    "tags": [
      "megane sunglasses eyewear_on_head red-framed_eyewear"
    ]
  },
  {
    "name": "Upper Body",
    "tags": [
      "sailor_collar choker shirt crop_top camisole dress bra babydoll"
    ]
  },
  {
    "name": "Lower Body",
    "tags": [
      "skirt pleated_skirt pantsu thighhighs shoes sandals socks pants shorts short_shorts"
    ]
  },
  {
    "name": "Traditional\u00A0Clothes",
    "tags": [
      "serafuku kimono kindergarten_uniform chinese_clothes"
    ]
  },
  {
    "name": "Wear/Other",
    "tags": [
      "armor suit uniform school_uniform underwear_only nude completely_nude"
    ]
  },
  {
    "name": "Actions",
    "tags": [
      "battle fighting jumping running princess_carry stretch sleeping lying flying squatting"
    ]
  },
  {
    "name": "Posture",
    "tags": [
      "all_fours arched_back back-to-back bent-over fighting_stance leaning leaning_back leaning_forward squat top-down_bottom-up"
    ]
  },
  {
    "name": "Arms",
    "tags": [
      "arms_behind_back arms_crossed arm_support arm_up arms_up arms_behind_head chin_rest outstretched_arm outstretched_arms spread_arms v_arms"
    ]
  },
  {
    "name": "Hands",
    "tags": [
      "hands_clasped hand_in_pocket hands_in_pocket hand_on_cheek hand_on_hat hand_on_head hand_on_hip hands_on_hip hand_on_shoulder holding_hands interlocked_fingers outstretched_hand"
    ]
  },
  {
    "name": "Legs",
    "tags": [
      "knees_on_chest leg_lift leg_up legs_up outstretched_leg pigeon_toed spread_legs"
    ]
  },
  {
    "name": "Sitting",
    "tags": [
      "sitting crossed_legs indian_style leg_hug seiza sitting_on_lap sitting_on_person wariza yokozuwari straddling"
    ]
  },
  {
    "name": "Standing",
    "tags": [
      "standing crossed_legs_(standing) standing_on_one_leg"
    ]
  },
  {
    "name": "Lying",
    "tags": [
      "lying on_back on_side on_stomach"
    ]
  },
  {
    "name": "Viewing Direction",
    "tags": [
      "eye_contact looking_at_viewer looking_back looking_away"
    ]
  },
  {
    "name": "Gesture",
    "tags": [
      "clenched_hand clenched_hands double_v heart_hands pinky_out pointing shushing thumbs_up \\\\m\\/ reaching salute waving cat_pose paw_pose v claw_pose double_\\\\m\\/"
    ]
  },
  {
    "name": "Facial Expressions",
    "tags": [
      "expressions expressionless ahegao anger_vein blush blush_stickers clenched_teeth closed_eyes evil naughty_face nosebleed open_mouth parted_lips pout rolleyes frown tears scream"
    ]
  },
  {
    "name": "Emotions",
    "tags": [
      "angry annoyed embarrassed happy sad scared surprised worried disappointed drunk trembling"
    ]
  },
  {
    "name": "Sex",
    "tags": [
      "sex anal clothed_sex happy_sex vaginal yaoi yuri tribadism oral"
    ]
  },
  {
    "name": "Positions",
    "tags": [
      "69 doggystyle girl_on_top cowgirl_position reverse_cowgirl_position upright_straddle missionary"
    ]
  },
  {
    "name": "Stimulation",
    "tags": [
      "buttjob footjob grinding thigh_sex tekoki caressing_testicles double_handjob masturbation crotch_rub paizuri naizuri"
    ]
  },
  {
    "name": "Oral",
    "tags": [
      "oral breast_sucking cunnilingus facesitting fellatio deepthroat :>="
    ]
  },
  {
    "name": "Groping",
    "tags": [
      "groping ass_grab breast_grab nipple_tweak self_fondle torso_grab"
    ]
  },
  {
    "name": "Group Sex",
    "tags": [
      "group_sex gangbang double_penetration orgy spitroast teamwork threesome"
    ]
  },
  {
    "name": "Insertion",
    "tags": [
      "insertion anal_insertion large_insertion stomach_bulge multiple_insertions urethral_insertion penetration nipple_penetration fingering anal_fingering"
    ]
  },
  {
    "name": "Fetishes",
    "tags": [
      "milf giantess minigirl plump fat skinny public public_nudity zenra exhibitionism voyeurism futa_on_female futa_on_male incest twincest rape about_to_be_raped molestation bestiality impregnation tentacles virgin vore"
    ]
  },
  {
    "name": "Bondage",
    "tags": [
      "bondage bdsm asphyxiation breast_bondage shibari spreader_bar suspension femdom humiliation body_writing slave spanked torture bound_arms bound_legs bound_wrists suspension"
    ]
  },
  {
    "name": "Semen",
    "tags": [
      "semen bukkake dripping_semen semen_splatter semen_pool nakadashi semen_in_anus semen_in_mouth semen_on_tongue semen_on_body semen_on_hair semen_on_lower_body semen_on_ass semen_on_vagina semen_on_upper_body semen_on_breasts semen_on_clothes ejaculation ejaculating_while_penetrated facial"
    ]
  },
  {
    "name": "Objects",
    "tags": [
      "condom used_condom sex_toy dildo vibrator"
    ]
  },
  {
    "name": "Bodily Fluids",
    "tags": [
      "blood lactation urinating saliva sweat female_ejaculation vaginal_juices"
    ]
  },
  {
    "name": "View",
    "tags": [
      "cross-section internal_cumshot x-ray"
    ]
  },
  {
    "name": "Background",
    "tags": [
      "simple_background gradient_background two-tone_background ambiguous_background blurry_background",
      ["white_background grey_background black_background red_background brown_background orange_background yellow_background green_background blue_background purple_background pink_background"]      
    ]
  },
  {
    "name": "Placement",
    "tags": [
      "indoors outdoors rooftop city pool beach cave bedroom hallway"
    ]
  },
  {
    "name": "Nature",
    "tags": [
      "ocean river tree palm_tree wisteria lilac grass sand water",
      ["white_flower red_flower yellow_flower blue_flower purple_flower pink_flower"]
    ]
  },
  {
    "name": "Indoors",
    "tags": [
      "pillow bed door bed_sheet counter window curtains bathtub"
    ]
  },
  {
    "name": "Work Type",
    "tags": [
      "scan watercolor_(medium) papercraft non-web_source photoshop_(medium) sketch work_in_progress lineart"
    ]
  }
]`;

  /*****************/
  /* compatibility */
  /*****************/

  let IS_MONKEY = false;        // Tampermonkey, Violentmonkey, Greasemonkey (all at least partially support 'GM.' functions)
  let IS_GREASEMONKEY4 = false; // script breaking changes (see TODO)

  if (typeof GM !== 'undefined' && typeof GM.info === 'object') {
    IS_MONKEY = true;
    IS_GREASEMONKEY4 = (GM.info.scriptHandler === 'Greasemonkey');

    // polyfills for ViolentMonkey
    if (typeof GM.addValueChangeListener === 'undefined') GM.addValueChangeListener = GM_addValueChangeListener;
    if (typeof GM.registerMenuCommand    === 'undefined') GM.registerMenuCommand    = GM_registerMenuCommand; // doesn't support accessKey
  }

  function open_in_tab(url) {
    if (IS_MONKEY) GM.openInTab(url, false);
    else           window.open(url); // requires popup permission
  }

  function add_style(css) {
    if (IS_MONKEY) {
      GM.addStyle(css);
    } else {
      const sheet = document.createElement('STYLE');
      sheet.innerText = css;
      document.head.appendChild(sheet);
    }
  }

  function set_clipboard(text) {
    if (IS_MONKEY) {
      GM.setClipboard(text);
    } else {
      navigator.clipboard.writeText(text).catch((err) => {
        show_notice(console.error, '[addon error]: Couldn\'t copy text to clipboard', err);
      });
    }
  }

  // the site uses a ton of ancient, non-standard polyfills/prototype overrides, e.g.
  // Array.from(new Set([1])) returns [] instead of [1]
  // JSON.parse(JSON.stringify([1])) returns "[1]" instead of [1]
  // Array.from(s) can be replaced by [...s]
  // to use proper JSON we need to temporarily unbind the site's toJSON functions
  const toJSON_OBJECTS = [Object, Array.prototype, Number.prototype, String.prototype];

  function delete_toJSONs() {
    const toJSON_originals = [];
    for (const obj of toJSON_OBJECTS) {
      if (obj.hasOwnProperty('toJSON')) {
        toJSON_originals.push({ obj, func: obj.toJSON });
        delete obj.toJSON;
      }
    }
    return toJSON_originals;
  }

  function restore_toJSONs(toJSON_originals) {
    for (const { obj, func } of toJSON_originals)
      obj.toJSON = func;
  }

  function JSON_stringify(obj, replacer, space) {
    let toJSON_originals;
    try {
      toJSON_originals = delete_toJSONs();
      return JSON.stringify(obj, replacer, space);
    } finally {
      restore_toJSONs(toJSON_originals);
    }
  }

  // enables JSON to stringify Sets and Hotkeys
  function json_replacer(key, value) {
    if (typeof value === 'object') {
      if (value instanceof Set)  return { t: 'Set', v: [...value] };
      if (value instanceof Map)  return { t: 'Map', v: [...value] };
      if (value instanceof Hotkey) {
        return { t: 'Hotkey', v: {
            modifiers: value.modifiers,
            action: value.action.name,
          } };
      }
    }

    return value;
  }

  // enables JSON to parse Sets and Hotkeys
  function json_reviver(key, value) {
    if (typeof value === 'object') {
      switch (value.t) {
        case 'Set':
          return new Set(value.v);
        case 'Map':
          return new Map(value.v);
        case 'Hotkey':
          return new Hotkey(value.v.modifiers, POSTPAGE_ACTIONS[value.v.action]);
      }
    }

    return value;
  }


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

  const IS_IDOL = (window.location.hostname === 'idol.sankakucomplex.com' ? 1 : 0);
  const HISTORY_KEY = (IS_IDOL ? 'view_history_idol' : 'view_history');
  const COMMON_TAGS_KEYS = ['common_tags_json', 'common_tags_json_idol'];
  const COMMON_TAGS_KEY = COMMON_TAGS_KEYS[IS_IDOL];
  const OTHER_COMMON_TAGS_KEY = COMMON_TAGS_KEYS[1 - IS_IDOL];

  const POSTPAGE_ACTIONS = { // for hotkeys
    reset_size: () => {
      scale_image(SCALE_MODES.RESET, true);
      scroll_to_image(config.scroll_to_image_center);
    },
     fit_size: () => {
      scale_image(SCALE_MODES.FIT, true);
      scroll_to_image(config.scroll_to_image_center);
    },
    fit_horizontal: () => {
      scale_image(SCALE_MODES.HORIZONTAL, true);
      scroll_to_image(config.scroll_to_image_center);
    },
    fit_vertical: () => {
      scale_image(SCALE_MODES.VERTICAL, true);
      scroll_to_image(config.scroll_to_image_center);
    },
    open_similar: () => {
      if (post_id === null) {
        show_notice(console.error, 'addon error: couldn\'t find post id?!');
      } else {
        open_in_tab(window.location.origin + '/post/similar?id=' + post_id);
      }
    },
    open_delete: () => {
      if (post_id === null) {
        show_notice(console.error, 'addon error: couldn\'t find post id?!');
      } else if (!found_delete_action) {
        show_notice(console.error, 'addon error: Delete action not found, no permission?');
      } else {
        open_in_tab(window.location.origin + '/post/delete/' + post_id);
      }
    },
    add_translation: () => {
      if (post_id === null) {
        show_notice(console.error, 'addon error: couldn\'t find post id?!');
      } else {
        unsafeWindow.Note.create(post_id);
      }
    },
  };

  class Hotkey {
    constructor(modifiers, action) {
      this.modifiers = (modifiers === undefined ? new Set() : modifiers);
      this.action = action;
    }

    call(e) {
      if (this.modifiers.has('ctrl') === e.ctrlKey
        && this.modifiers.has('alt') === e.altKey
        && this.modifiers.has('shift') === e.shiftKey) {
        this.action();
      }
    }
  }

  const 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,
    load_highres: false,
    highres_limit: 4000000, // bytes
    video_pause: false,
    video_mute: true,
    set_video_volume: false,
    video_volume: 50,
    video_controls: true,
    show_speaker_icon: true,
    show_animated_icon: true,
    show_ratings_icon: false,
    setparent_deletepotentialduplicate: false,
    editform_deleteuselesstags: false,
    hide_headerlogo: false,
    tag_search_buttons: true,
    or_tag_search_button: false,
    tag_menu: true,
    tag_menu_scale: '30%',
    tag_menu_layout: 1,
    common_tags_json: DEFAULT_TAGLIST,
    common_tags_json_idol: '[ {"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\u00A0category", "tags":["t1 t2 t3"]} ]',
    sankaku_channel_dark_compatibility: false,
    view_history_enabled: false,
    view_history: new Set(),
    view_history_idol: new Set(),
    wiki_template: '',
    record_template:
`[
  [
    "Poor Tagging - neutral",
    "Hello.\\n\\nPlease comply with our [[uploading rules]] when making new posts. [##]% of your posts do not have enough tags to get rid of their tagme status.\\n\\nIf you need help figuring out what tags to add, there's a great [[tag checklist]] that will give you lots of tags to add.\\n\\nUntil you correct this issue, I'll have to request that you not upload anything else. A failure to comply with our tagging standards can result in further staff action against your account.\\n\\nAs an extra bit of information, we require 13 general tags on color posts and 7 general tags on [[monochrome]] posts to avoid records such as this one.",
    "neutral"
  ]
]`,
    tagscript_presets:
`[
  [
    "Remove potential_duplicate",
    "-potential_duplicate"
  ],
  [
    "futanari -> newhalf",
    "newhalf -futanari -full-package_futanari"
  ]
]`,
    tag_category_collapser: false,
    tag_category_collapser_style: 0,
    collapsed_tag_categories: new Set(),
    add_filetype_stat: true,
    move_stats_to_edit_form: false,
    postpage_hotkeys: {
      r: new Hotkey(new Set(), POSTPAGE_ACTIONS.reset_size),
      f: new Hotkey(new Set(), POSTPAGE_ACTIONS.fit_size),
      g: new Hotkey(new Set(), POSTPAGE_ACTIONS.fit_horizontal),
      h: new Hotkey(new Set(), POSTPAGE_ACTIONS.fit_vertical),
      s: new Hotkey(new Set(), POSTPAGE_ACTIONS.open_similar),
      d: new Hotkey(new Set(), POSTPAGE_ACTIONS.open_delete),
      t: new Hotkey(new Set(), POSTPAGE_ACTIONS.add_translation),
    },
  };

  const USE_MONKEY_STORAGE = IS_MONKEY;
  let USE_LOCAL_STORAGE;
  try {
    USE_LOCAL_STORAGE = !!localStorage.getItem;
  } catch (error) { // DOMException
    USE_LOCAL_STORAGE = false;
  }

  const KEY_PREFIX = 'config.'; // used to avoid conflicts in localStorage and config element ids

  const config = Object_clone(DEFAULT_CONFIG); // load default

  // applied to loaded/set config entries (e.g. config elements return strings when we need numbers)
  const CONFIG_FIXER = {
    scale_mode: Number,
    tag_menu_layout: Number,
    tag_category_collapser_style: Number,
    highres_limit: Number,
  };

  function fix_config_entry(key, value) {
    const fixer = CONFIG_FIXER[key];
    return (fixer !== undefined ? fixer(value) : value);
  }

  // permanently save setting to localStorage (and broadcast to other tabs)
  function save_setting(key, value) {
    value = fix_config_entry(key, value);

    if (USE_MONKEY_STORAGE) {
      GM.setValue(key, JSON_stringify(value, json_replacer)).catch((reason) => {
        show_notice(console.error, `addon error: couldn't save setting "${key}", check console`, reason);
      });
      return;
    }

    if (!USE_LOCAL_STORAGE) {
      show_notice(console.warn, `[addon] couldn't save setting "${KEY_PREFIX + key}" to localStorage. check permissions`);
      return;
    }

    try {
      localStorage.setItem(KEY_PREFIX + key, JSON_stringify(value, json_replacer));
    } catch (error) {
      show_notice(console.error, `[addon error] couldn't save setting "${KEY_PREFIX + key}" to localStorage, check console`, error);
    }
  }

  async function load_config() {
    const monkey_values = {};

    if (USE_MONKEY_STORAGE) {
      const promises = [];
      for (const key of Object.keys(config)) {
        promises.push(GM.getValue(key).then((value) => {
          monkey_values[key] = value;
        }));
      }

      await Promise.all(promises);
    }

    for (const key of Object.keys(config)) {
      let value = config[key]; // default already loaded

      let stored_value = monkey_values[key];

      if (stored_value === undefined && USE_LOCAL_STORAGE)
        stored_value = localStorage.getItem(KEY_PREFIX + key);

      if (stored_value !== undefined && stored_value !== null) {
        try {
          value = JSON.parse(stored_value, json_reviver);
        } catch (error) {
          show_notice(console.error, `[addon error] couldn't load setting "${key}"`, error);
        }
      }

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

  function storage_changed(key, old_value, new_value, remote) {
    try {
      if (!remote) return; // only listen to other tabs

      if (new_value === undefined || new_value === null) {
        // entry was removed, reset setting to default
        update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
      } else {
        // entry was added or changed
        const value = JSON.parse(new_value, json_reviver);

        // workaround for post view history race condition
        if (key === HISTORY_KEY) {
          const new_ids = Set_difference(value, config[key]);
          if (new_ids.size === 0) return;

          // integrate newly received post ids into view history
          config[key] = Set_union(value, config[key]);

          // save new view history and broadcast it to other tabs,
          // which in turn might broadcast their ids back to us
          save_setting(key, value);

          // live update thumbnails
          if (!is_personal_post_page()) {
            for (const id of new_ids) {
              const thumbs = thumbnail_cache.get(id);
              if (thumbs === undefined) continue;

              for (const thumb of thumbs)
                fadeout_post(thumb);
            }
          }

          return; // don't call update_setting()
        }

        update_setting(key, value);
      }
    } catch (error) {
      show_notice(console.error, 'storage_changed() failed, check console', error);
    }
  }

  // localStorage from other tabs changed
  function local_storage_changed(e) {
    if (e.storageArea !== localStorage) return;
    if (e.key === null) return; // ignore external localStorage.clear() for now

    // only look at SankakuAddon specific changes
    if (!e.key.startsWith(KEY_PREFIX)) return;
    const key = e.key.substring(KEY_PREFIX.length);

    storage_changed(key, e.oldValue, e.newValue, true);
  }

  function update_setting(key, value) {
    config[key] = fix_config_entry(key, value);

    if (key === 'scale_on_resize') {
      if (value) add_scale_on_resize_listener();
      else       remove_scale_on_resize_listener();
    }

    update_config_dialog_by_key(key);

    if (key === 'hide_headerlogo') {
      update_headerlogo();
    } else if (key === 'collapsed_tag_categories') {
      for (const category of collapser_map.keys())
        collapse_tag_category(category, config.collapsed_tag_categories.has(category), false);
    }
  }

  function reset_setting(key) {
    if (USE_MONKEY_STORAGE) GM.deleteValue(key);
    if (USE_LOCAL_STORAGE) localStorage.removeItem(KEY_PREFIX + key); // also delete if USE_MONKEY_STORAGE
    update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
  }

  function reset_config() {
    for (const key of Object.keys(config)) {
      // don't clear the history so the clear history button makes more sense
      if (key === 'view_history' || key === 'view_history_idol') continue;
      // don't reset the common tags list of the other site
      if (key === OTHER_COMMON_TAGS_KEY) continue;

      reset_setting(key);
    }
  }


  // templates for the config dialog
  const CONFIG_TABS_TEMPLATE = {
    general: {
      name: 'General',
      categories: ['post', 'general'],
    },
    editing: {
      name: 'Editing',
      categories: ['editing'],
    },
    hotkeys: {
      name: 'Hotkeys',
      categories: ['postpage_hotkeys'],
    },
  };

  const CONFIG_CATEGORY_TEMPLATE = {
    post: {
      name: 'Image/Video',
      entries: [
        'scroll_to_image',
        'scroll_to_image_center',
        'scale_image',
        'scale_only_downscale',
        'scale_flash',
        'scale_on_resize',
        'scale_mode',
        'load_highres',
        'video_pause',
        'video_mute',
        'set_video_volume',
        'video_controls',
      ],
    },
    general: {
      name: 'General',
      entries: [
        'tag_search_buttons',
        'or_tag_search_button',
        'tag_category_collapser',
        'tag_category_collapser_style',
        'show_speaker_icon',
        'show_animated_icon',
        'show_ratings_icon',
        'view_history_enabled',
        'hide_headerlogo',
        'sankaku_channel_dark_compatibility',
      ],
    },
    editing: {
      name: 'Editing',
      entries: [
        'add_filetype_stat',
        'move_stats_to_edit_form',
        'setparent_deletepotentialduplicate',
        'editform_deleteuselesstags',
        'tag_menu',
        COMMON_TAGS_KEY,
        'tag_menu_layout',
        'wiki_template',
        'record_template',
        'tagscript_presets',
      ],
    },
    postpage_hotkeys: {
      name: 'Post Page Hotkeys',
      entries: [],
    },
  };

  // expand postpage_hotkeys
  for (const key of Object.keys(POSTPAGE_ACTIONS)) {
    CONFIG_CATEGORY_TEMPLATE.postpage_hotkeys.entries.push('postpage_hotkeys.' + key);
  }

  const SETTINGS_TEMPLATE = {
    scroll_to_image:                    {type: 'checkbox', desc: 'Scroll to image/video when opening post'},
    scroll_to_image_center:             {type: 'checkbox', desc: 'Scroll to center of image/video, else scroll to top'},
    scale_image:                        {type: 'checkbox', desc: 'Scale image/video when opening post'},
    scale_only_downscale:               {type: 'checkbox', desc: 'Only downscale'},
    scale_flash:                        {type: 'checkbox', desc: 'Also scale flash videos'},
    scale_on_resize:                    {type: 'checkbox', desc: 'Scale image on window resize', title: 'This uses the \'scale image mode\' setting, so it doesn\'t work well when using the manual scaling actions.'},
    scale_mode:                         {type: 'select',   desc: 'Scale image/video mode: ', options: {0: 'Fit to window', 1: 'Fit horizontally', 2: 'Fit vertically'}},
    load_highres:                       {type: 'checkbox', desc: 'Load original image if smaller than ', title: 'Set to 0 bytes to always load'},
    video_pause:                        {type: 'checkbox', desc: 'Pause (non-flash) videos*'},
    video_mute:                         {type: 'checkbox', desc: 'Mute (non-flash) videos*'},
    set_video_volume:                   {type: 'checkbox', desc: 'Set (non-flash) video volume to: '},
    video_controls:                     {type: 'checkbox', desc: 'Show video controls*'},
    tag_search_buttons:                 {type: 'checkbox', desc: 'Enable + - tag search buttons*'},
    or_tag_search_button:               {type: 'checkbox', desc: 'Also add ~ tag search button*'},
    show_speaker_icon:                  {type: 'checkbox', desc: 'Show 🔊 icon on thumbnail if it has audio*'},
    show_animated_icon:                 {type: 'checkbox', desc: 'Show ⏩ icon on thumbnail if it is animated (🔊 overrides ⏩)*'},
    show_ratings_icon:                  {type: 'checkbox', desc: 'Show ratings icon (S, Q, E) on post thumbnails*'},
    view_history_enabled:               {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'},
    setparent_deletepotentialduplicate: {type: 'checkbox', desc: 'Delete potential_duplicate tag when using "Set Parent"'},
    editform_deleteuselesstags:         {type: 'checkbox', desc: '"Save changes" button deletes useless_tags tag (if there have been changes)'},
    tag_category_collapser:             {type: 'checkbox', desc: 'Enable tag category collapsers on post pages*'},
    tag_category_collapser_style:       {type: 'select',   desc: 'Tag category collapser style: ', options: {0: 'Compact', 1: 'With category name'}},
    hide_headerlogo:                    {type: 'checkbox', desc: 'Hide header logo'},
    sankaku_channel_dark_compatibility: {type: 'checkbox', desc: 'Galinoa\'s Sankaku Channel Dark compatibilty*'},
    tag_menu:                           {type: 'checkbox', desc: 'Activate tag menu*'},
    [COMMON_TAGS_KEY]:                  {type: 'text',     desc: 'Common tags list (JSON format):'},
    tag_menu_layout:                    {type: 'select',   desc: 'Tag menu layout: ', options: {0: 'Normal', 1: 'Vertically compact'}},
    wiki_template:                      {type: 'text',     desc: 'Wiki template:', title: 'Text that will be be shown in a separate textarea on wiki add/edit pages so it can easily be copied'},
    record_template:                    {type: 'text',     desc: 'Record templates (JSON format):', title: 'A list of templates to be chosen from a dropdown menu on the record add page, each entry has a title followed by the actual content.'},
    tagscript_presets:                  {type: 'text',     desc: 'Tag script presets (JSON format):', title: 'A list of tag scripts to be chosen from a dropdown menu below "Mode" (put [] to disable).'},
    add_filetype_stat:                  {type: 'checkbox', desc: 'Add file type to post "Details"*'},
    move_stats_to_edit_form:            {type: 'checkbox', desc: 'Move post "Details" to the right of the edit form*'},
    'postpage_hotkeys.reset_size':      {type: 'hotkey',   desc: 'Reset Image Size'},
    'postpage_hotkeys.fit_size':        {type: 'hotkey',   desc: 'Fit Image'},
    'postpage_hotkeys.fit_horizontal':  {type: 'hotkey',   desc: 'Fit Image (Horizontal)'},
    'postpage_hotkeys.fit_vertical':    {type: 'hotkey',   desc: 'Fit Image (Vertical)'},
    'postpage_hotkeys.open_similar':    {type: 'hotkey',   desc: 'Find Similar'},
    'postpage_hotkeys.open_delete':     {type: 'hotkey',   desc: 'Delete Post'},
    'postpage_hotkeys.add_translation': {type: 'hotkey',   desc: 'Add Translation'},
  };

  // whether a config element's value are accessed via '.value' (or otherwise '.checked')
  function is_value_element(key) {
    // hardcoded elements
    if (key === 'video_volume')   return true;
    if (key === 'highres_limit')  return true;
    if (key === 'tag_menu_scale') return true; // doesn't exist as an element, but it would be '.value' type

    const type = SETTINGS_TEMPLATE[key].type;
    return (type === 'select' || type === 'text');
  }

  // calls f(cfg_elem, key, get_value) for each existing config element
  function foreach_config_element(f) {
    for (const key of Object.keys(config)) {
      const cfg_elem = document.getElementById(KEY_PREFIX + key);
      if (cfg_elem === null) continue;

      if (is_value_element(key)) f(cfg_elem, key, () => cfg_elem.value);
      else                       f(cfg_elem, key, () => cfg_elem.checked);
    }
  }

  function update_config_dialog_by_key(key) {
    if (key === 'postpage_hotkeys') {
      update_postpage_hotkeys();
      return;
    }

    const cfg_elem = document.getElementById(KEY_PREFIX + key);
    if (cfg_elem !== null) {
      if (is_value_element(key)) cfg_elem.value   = config[key];
      else                       cfg_elem.checked = config[key];
    }
  }

  function update_config_dialog() {
    for (const key of Object.keys(config)) 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 ? 'block' : 'none');
  }


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

  const EMPTY_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';

  function set_cookie(name, value, valid_for_days = 365) {
    const date = new Date();
    date.setTime(date.getTime() + (valid_for_days * 24 * 60 * 60 * 1000));
    document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/`;
  }

  function get_cookie(name) {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
      const kv = cookie.split('=');
      if (kv.length === 2 && kv[0].trim() === name) {
        return decodeURIComponent(kv[1]);
      }
    }
    return '';
  }

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  // helper function to modify nodes on creation
  function register_observer(node_predicate, node_modifier) {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node_predicate(node)) {
            if (node_modifier(node, observer)) { // are we done?
              observer.disconnect();
              return;
            }
          }
        }
      }
    });

    observer.observe(document, { childList: true, subtree: true });

    return observer;
  }

  function get_scrollbar_width() {
    const div = document.createElement('DIV');
    div.style.overflow = 'scroll';
    document.body.appendChild(div);
    const scrollbar_width = div.offsetWidth - div.clientWidth;
    div.remove();
    return scrollbar_width;
  }

  // (almost) deepclone an object
  function Object_clone(obj) {
    if (typeof obj === 'object') {
      // shallow clone containers
      if (obj instanceof Array) return [...obj];
      if (obj instanceof Set) return new Set(obj);
      if (obj instanceof Map) return new Map(obj);
      if (obj instanceof Hotkey) return new Hotkey(new Set(obj.modifiers), obj.action);

      const new_obj = {};
      for (const [key, value] of Object.entries(obj))
        new_obj[key] = Object_clone(value);
      return new_obj;
    }

    return obj;
  }

  function Set_difference(a, b) {
    return new Set([...a].filter((x) => !b.has(x)));
  }

  function Set_union(a, b) {
    return new Set([...a, ...b]);
  }

  function insert_node_after(node, ref_node) {
    ref_node.parentNode.insertBefore(node, ref_node.nextSibling);
  }

  function get_resolution(obj) {
    if (obj.src === 'about:blank') return null;

    // natural size only for images, can be 0 when not yet loaded
    // note: when src is changed, this can read the old size
    if (obj.naturalWidth && obj.naturalHeight) {
      return [obj.naturalWidth, obj.naturalHeight];
    }

    if (obj.videoWidth && obj.videoHeight) {
      return [obj.videoWidth, obj.videoHeight];
    }

    return null;
  }

  function show_notice(logFunc, ...msg) {
    if (unsafeWindow.notice) unsafeWindow.notice(msg[0]);
    if (logFunc) logFunc(...msg);
  }

  function get_original_background_color() {
    // the background-color style gets changed through the (site)script, but we need the original one
    // there has to be a better way than this, right?
    const current = window.getComputedStyle(document.body).getPropertyValue('background-color');
    document.body.style.backgroundColor = '';
    const original = window.getComputedStyle(document.body).getPropertyValue('background-color');
    document.body.style.backgroundColor = current;
    return original;
  }

  // "rgb(r,g,b)" -> [int(r), int(g), int(b)]
  function rgb_to_array(rgb) {
    const arr = rgb.substring(rgb.indexOf('(') + 1, rgb.lastIndexOf(')')).split(/,\s*/);
    for (let i = 0; i < arr.length; i++)
      arr[i] = parseInt(arr[i], 10);
    return arr;
  }

  function rgb_array_is_dark(rgb_array) {
    let avg = 0;
    for (let i = 0; i < rgb_array.length; i++)
      avg += rgb_array[i];
    avg /= rgb_array.length;

    return (avg <= 128);
  }

  function rgb_array_shift(rgb, shift) {
    const shifted = [];
    for (let i = 0; i < 3; i++)
      shifted.push(Math.min(Math.max(rgb[i] + shift, 0), 255));

    return shifted;
  }

  // [r, g, b] -> "rgb(r,g,b)"
  function rgb_array_to_rgb(rgb) {
    if (rgb.length === 3)
      return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
    return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ')';
  }

  function is_darkmode() {
    const theme = get_cookie('theme');
    if (theme !== '' && Number(theme) !== 0)
      return true;

    // fallback
    const rgb = rgb_to_array(get_original_background_color());
    return rgb_array_is_dark(rgb);
  }

  // helper function to adjust background colors based on light or dark mode
  function shifted_backgroundColor(shift) {
    const rgb = rgb_to_array(get_original_background_color());
    const shifted_rgb = rgb_array_shift(rgb, (is_darkmode() ? 1 : -1) * shift);
    return rgb_array_to_rgb(shifted_rgb);
  }

  function create_button() {
    const el = document.createElement('BUTTON');
    el.style.cursor = 'pointer';
    return el;
  }

  function create_popup_menu() {
    const popup = document.createElement('DIV');
    popup.style.display = 'none';
    popup.style.padding = '6px 12px 6px 12px';
    popup.style.border = '1px solid ' + shifted_backgroundColor(32);
    popup.style.backgroundColor = get_original_background_color();
    // fixed, centered div
    popup.style.top  = '50%';
    popup.style.left = '50%';
    popup.style.transform = 'translate(-50%, -50%)';
    popup.style.position = 'fixed';
    popup.style.zIndex = '10002';
    // scroll bars if too large (resizing textareas behaves a bit weirdly on Chrome because it sets margins)
    popup.style.minWidth  = '30vw';
    popup.style.maxWidth  = '90vw';
    popup.style.maxHeight = '90vh';
    popup.style.overflow = 'auto';
    return popup;
  }


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

  const thumbnail_cache = new Map(); // id -> array of thumbnail elements
  let cached_search_tags = null;

  function get_search_tags() {
    if (cached_search_tags !== null) return cached_search_tags;

    const tags = new URL(window.location.href).searchParams.get('tags');
    cached_search_tags = (tags === null ? [] : tags.split(' '));

    return cached_search_tags;
  }

  // is own uploads or favorites page
  function is_personal_post_page() {
    const username = get_username();
    if (username === null) return false;

    const tags = get_search_tags();
    return tags.includes('fav:' + username) || tags.includes('user:' + username);
  }

  function hide_headerlogo(hide) {
    const headerlogo = document.getElementById('headerlogo');
    if (headerlogo !== null) headerlogo.style.display = (hide ? 'none' : '');
  }

  function add_config_dialog() {
    const cfg_dialog = create_popup_menu();
    cfg_dialog.style.zIndex = '10010';
    cfg_dialog.id = 'cfg_dialog';

    // generate the content of the config menu
    let innerDivHTML = `<div style='font-weight: bold; margin-bottom: 6px;'>SankakuAddon ${VERSION}</div>`;
    // + `<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid ${shifted_backgroundColor(32)};'>`;

    // add tabs, TODO: they're ugly especially in dark mode
    innerDivHTML += '<div id="cfg_tabs" style="display: flex; white-space: nowrap;">';
    for (const [key, value] of Object.entries(CONFIG_TABS_TEMPLATE))
      innerDivHTML += `<button id="cfg_tab_${key}" style="border-style: solid; padding: 0 2px 0 2px; margin: 0 2px 0 2px; background-color: transparent; border-bottom-width: 0; cursor: pointer">${value.name}</button>`;
    innerDivHTML += '</div>';

    // add bodies
    for (const [body_key, body] of Object.entries(CONFIG_CATEGORY_TEMPLATE)) {
      innerDivHTML += `<div id="cfg_body_${body_key}" style="background-color: rgba(128, 128, 128, 0.1); margin-bottom: 4px; padding: 0 4px 2px 4px;">`
        + `<h5>${body.name}</h5>`;

      // add config elements for each body
      for (const key of body.entries) {
        const value = SETTINGS_TEMPLATE[key];

        const generate_span = () => `<span style="vertical-align: middle; ${value.title ? 'cursor:help; text-decoration: underline dashed; ' : ''}" `
          + `${value.title ? `title="${value.title}"` : ''} >${value.desc}</span>`;

        innerDivHTML += '<div>';
        switch (value.type) {
          case 'checkbox':
            innerDivHTML += `<input id='${KEY_PREFIX}${key}' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
            innerDivHTML += generate_span();
            // hardcoded elements:
            innerDivHTML += (key === 'set_video_volume' ? `<input id="${KEY_PREFIX}video_volume" type="number" min="0" max="100" size="4">%` : '');
            innerDivHTML += (key === 'load_highres' ? `<input id="${KEY_PREFIX}highres_limit" type="number" min="0" max="4000000" size="10"> bytes` : '');
            break;
          case 'select':
            innerDivHTML += generate_span();
            innerDivHTML += `<select id="${KEY_PREFIX}${key}">`;
            for (const [k, v] of Object.entries(value.options))
              innerDivHTML += `<option value="${k}">${v}</option>`;
            innerDivHTML += '</select>';
            break;
          case 'text':
            innerDivHTML += generate_span();
            innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" rows=8 style='width: 100%; box-sizing: border-box'></textarea>`;
            break;
          case 'hotkey':
            innerDivHTML += `<input id='${KEY_PREFIX}${key}_ctrl' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
            innerDivHTML += '<span style="vertical-align: middle;">ctrl</span>';
            innerDivHTML += `<input id='${KEY_PREFIX}${key}_alt' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
            innerDivHTML += '<span style="vertical-align: middle;">alt</span>';
            innerDivHTML += `<input id='${KEY_PREFIX}${key}_shift' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
            innerDivHTML += '<span style="vertical-align: middle;">shift</span>';
            innerDivHTML += `<input id='${KEY_PREFIX}${key}_key' type='text' maxLength='1' size='1' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
            innerDivHTML += generate_span();
            break;
          default:
            show_notice(console.error, '[addon error] CONFIG_TEMPLATE is defective!', value.type);
            continue;
        }
        innerDivHTML += '</div>';
      }

      innerDivHTML += '</div>';
    }

    innerDivHTML += '<div style="padding: 2px">';
    innerDivHTML +=  '<button id="config_close" style="cursor: pointer;">Close</button>';
    innerDivHTML +=  '<button id="config_reset" style="cursor: pointer;" title="Resets all settings to default (but doesn\'t clear post history)">Reset settings</button>';
    innerDivHTML +=  '<button id="history_clear" style="cursor: pointer;" title="Clears the post view history for the current site (chan or idol)">Clear post view history</button>';
    innerDivHTML += '</div>';
    innerDivHTML += '<div style="padding: 2px">&nbsp;*requires a page reload.</div>';

    cfg_dialog.innerHTML = innerDivHTML;

    document.body.appendChild(cfg_dialog);

    // hide non-default categories
    for (const [other_tab_name, other_tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
      if (other_tab_name !== 'general') {
        for (const category of other_tab.categories)
          document.getElementById(`cfg_body_${category}`).style.display = 'none';
      }
    }

    // add events
    document.getElementById('config_close').onclick = () => { show_config_dialog(false); return false; };
    document.getElementById('config_reset').onclick = () => { reset_config(); return false; };
    document.getElementById('history_clear').onclick = () => { reset_setting(HISTORY_KEY); return false; };

    // tab events
    for (const [tab_name, tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
      document.getElementById(`cfg_tab_${tab_name}`).onclick = () => {
        // hide categories of other tabs
        for (const [other_tab_name, other_tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
          if (other_tab_name !== tab_name) {
            for (const category of other_tab.categories) {
              document.getElementById(`cfg_body_${category}`).style.display = 'none';
            }
          }
        }

        // show categories for given tab
        for (const category of tab.categories) {
          document.getElementById(`cfg_body_${category}`).style.display = 'block';
        }
      };
    }

    foreach_config_element((cfg_elem, key, get_value) => {
      cfg_elem.addEventListener('change', () => {
        update_setting(key, get_value());
        save_setting(key, get_value());
      });
    });

    // configure hotkey elements
    for (const name of Object.keys(POSTPAGE_ACTIONS)) {
      for (const suffix of ['ctrl', 'alt', 'shift', 'key']) {
        const cfg_elem = document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${name}_${suffix}`);
        cfg_elem.addEventListener('change', () => {
          const postpage_hotkeys = get_postpage_hotkeys();
          update_setting('postpage_hotkeys', postpage_hotkeys);
          save_setting('postpage_hotkeys', postpage_hotkeys);
        });
      }
    }
  }

  function get_postpage_hotkeys() { // get from config dialog
    const postpage_hotkeys = {};

    for (const [name, action] of Object.entries(POSTPAGE_ACTIONS)) {
      const modifiers = new Set();
      for (const mod of ['ctrl', 'alt', 'shift']) {
        if (document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${name}_${mod}`).checked) {
          modifiers.add(mod);
        }
      }

      const key = document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${name}_key`).value;
      if (key.length !== 0) {
        postpage_hotkeys[key] = new Hotkey(modifiers, action);
      }
    }

    return postpage_hotkeys;
  }

  function update_postpage_hotkeys() {
    // reset all hotkey config elements
    for (const name of Object.keys(POSTPAGE_ACTIONS)) {
      for (const mod of ['ctrl', 'alt', 'shift']) {
        const el = document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${name}_${mod}`);
        if (el !== null)
          el.checked = false;
      }
      const el = document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${name}_key`);
      if (el !== null)
        el.value = '';
    }

    // update hotkey config elements
    for (const [k, v] of Object.entries(config.postpage_hotkeys)) {
      const key_el = document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${v.action.name}_key`);
      if (key_el !== null)
        key_el.value = k;

      for (const mod of ['ctrl', 'alt', 'shift']) {
        const mod_el = document.getElementById(`${KEY_PREFIX}postpage_hotkeys.${v.action.name}_${mod}`);
        if (mod_el !== null)
          mod_el.checked = v.modifiers.has(mod);
      }
    }
  }

  function add_config_button() {
    const navbar = document.getElementById('navbar');
    if (navbar === null) return;

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

    const lang_select = navbar.querySelector('.lang-select');
    if (lang_select !== null)
      lang_select.style.borderRight = 0; // prevent config button from jumping a pixel on mouseover

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

    const li = document.createElement('LI');
    li.className = 'lang-select'; // match style of top bar
    li.style.paddingRight = '10px';
    li.appendChild(a);
    navbar.appendChild(li);
  }

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

    const items = tagsidebar.getElementsByTagName('LI');
    for (const item of items) {
      const taglink = item.getElementsByTagName('A')[0];
      const tagname = taglink.innerText.replaceAll(/ /g, '_'); // hopefully this is the only edgecase

      // generates click listeners
      const tag_search_button_func = function(tagname) {
        return function() {
          const search_field = document.getElementById('tags');

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

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

          search_field.focus({ preventScroll: true });

          return false;
        };
      };

      {
        const a = document.createElement('A');
        a.href = '#';
        a.innerText = '+';
        a.onclick = tag_search_button_func(tagname);

        item.insertBefore(a, taglink);
        item.insertBefore(document.createTextNode(' '), taglink);
      }

      {
        const a = document.createElement('A');
        a.href = '#';
        a.innerText = '-';
        a.onclick = tag_search_button_func('-' + tagname);

        item.insertBefore(a, taglink);
        item.insertBefore(document.createTextNode(' '), taglink);
      }

      if (config.or_tag_search_button) {
        const a = document.createElement('A');
        a.href = '#';
        a.innerText = '~';
        a.onclick = tag_search_button_func('~' + tagname);

        item.insertBefore(a, taglink);
        item.insertBefore(document.createTextNode(' '), taglink);
      }
    }
  }

  const collapser_map = new Map(); // category -> [collapser, tags]
  const collapser_color_map = new Map(); // category -> font color

  function collapse_tag_category(category, collapse, save = true) {
    const [collapser, tags] = collapser_map.get(category);

    const a = collapser.children[0];
    const middle_div = a.children[1];

    // collapse/expand category
    if (collapse) {
      for (const tag of tags)
        tag.style.display = 'none';
    } else {
      for (const tag of tags)
        tag.style.display = '';
    }

    // change collapser visuals
    if (config.tag_category_collapser_style === 0) { // compact style
      if (collapse) {
        middle_div.style.height = '0';
        middle_div.style.borderTopWidth = '3px';
        middle_div.style.borderBottomWidth = '3px';
        middle_div.style.marginTop = '3px';
        middle_div.style.marginBottom = '3px';
      } else {
        middle_div.style.height = '4px';
        middle_div.style.borderTopWidth = '2px';
        middle_div.style.borderBottomWidth = '2px';
        middle_div.style.marginTop = '2px';
        middle_div.style.marginBottom = '2px';
      }
    } else if (config.tag_category_collapser_style === 1) { // with category name
      if (collapse) {
        // swap border and font color
        middle_div.style.color = get_original_background_color();
        middle_div.style.backgroundColor = collapser_color_map.get(category);
      } else {
        middle_div.style.removeProperty('color');
        middle_div.style.removeProperty('background-color');
      }
    }

    // retain collapse state
    if (collapse) config.collapsed_tag_categories.add(category);
    else          config.collapsed_tag_categories.delete(category);

    if (save) save_setting('collapsed_tag_categories', config.collapsed_tag_categories);
  }

  let drag_collapse = false;
  let drag_collapse_categories;

  function drag_collapse_down(e) {
    e.preventDefault();
    drag_collapse = true;

    const category = e.currentTarget.className;
    drag_collapse_categories = !config.collapsed_tag_categories.has(category);

    collapse_tag_category(category, drag_collapse_categories);
  }

  function drag_collapse_move(e) {
    if (!drag_collapse) return;

    const category = e.currentTarget.className;

    if (drag_collapse_categories !== config.collapsed_tag_categories.has(category))
      collapse_tag_category(category, drag_collapse_categories);
  }

  function drag_collapse_up() {
    drag_collapse = false;
  }

  function add_tag_category_collapser() {
    const tagsidebar = document.getElementById('tag-sidebar');
    if (tagsidebar === null) return;

    const items = tagsidebar.getElementsByTagName('LI');

    window.addEventListener('mouseup', drag_collapse_up);

    const setup_collapser = (collapser, category, tags) => {
      collapser_map.set(category, [collapser, tags]);

      collapser.addEventListener('mousedown', drag_collapse_down);
      collapser.addEventListener('mousemove', drag_collapse_move);
      for (const tag of tags)
        tag.addEventListener('mousemove', drag_collapse_move);
    };

    let curr_category = null;
    let curr_category_tags = [];
    let prev_category_collapser = null;
    const categories = [];

    for (const item of items) {
      if (item.className === curr_category) {
        curr_category_tags.push(item);
      } else { // reached new category
        if (prev_category_collapser !== null)
          setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]);

        // remember category color, workaround for color changing on hover
        for (const a of item.getElementsByTagName('A')) {
          collapser_color_map.set(item.className, window.getComputedStyle(a).getPropertyValue('color'));
          break;
        }

        curr_category = item.className;
        curr_category_tags = []; // item will be pushed in the next iteration, see warning below
        categories.push(curr_category);

        const a = document.createElement('A');
        a.href = '#';
        a.addEventListener('click', (e) => e.preventDefault());

        // collapser visuals
        a.style.display = 'flex';
        a.style.justifyContent = 'center';
        a.style.alignItems = 'center';

        if (config.tag_category_collapser_style === 0) { // compact style
          a.innerHTML =
            '<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>' +
            '<div style="width:5%;  height: 4px; border-width: 2px; margin: 2px 2px 2px 2px; border-style: solid"></div>' +
            '<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>';
        } else { // with category name
          const category_name = curr_category.substring('tag-type-'.length);

          a.style.paddingLeft = '2.5%';
          a.style.paddingRight = '2.5%';
          a.innerHTML =
            '<div style="width:50%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>' +
            `<div style="border-width: 2px; margin: 2px 2px 2px 2px; padding-left: 4px; padding-right: 4px; border-style: solid">${category_name}</div>` +
            '<div style="width:50%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>';
        }

        const collapser = document.createElement('LI');
        collapser.className = item.className;
        collapser.appendChild(a);
        prev_category_collapser = collapser;

        // warning: modifies iterating list, current item will be processed twice
        item.insertAdjacentElement('beforebegin', collapser);
      }
    }

    // setup last collapser
    if (prev_category_collapser !== null)
      setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]);

    for (const category of categories)
      if (config.collapsed_tag_categories.has(category))
        collapse_tag_category(category, true, false);
  }

  function get_thumbnail_post_id(thumb_span) {
    const pid = thumb_span.id;
    if (typeof pid === 'undefined') return null; // first thumbnail on similar page doesn't have post id
    if (typeof pid !== 'string' || !pid.startsWith('p')) {
      console.error('[addon error] invalid thumbnail id');
      return null;
    }

    const id = Number(pid.substring(1));
    if (Number.isNaN(id)) {
      console.error('[addon error] thumbnail id isn\'t a number', id);
      return null;
    }

    return id;
  }

  // should be equivalent to unsafeWindow.Post.posts.get(get_thumbnail_post_id(thumb_span)).match_tags,
  // except unsafeWindow.Post might not yet be loaded.
  function get_thumbnail_tags(thumb_span) {
    const img = thumb_span.querySelector('.preview');
    if (img === null) return null;

    const tags = img.title.trim().split(/\s+/);
    return tags;
  }

  function modify_thumbnails(root) {
    // TODO: add_thumbnail_click_listener() would need to check for logged in status
    if (!(config.show_speaker_icon || config.show_animated_icon  || config.show_ratings_icon || config.view_history_enabled)) return;
    if (root === null) return;

    for (const thumb of root.getElementsByClassName('thumb')) {
      const id = get_thumbnail_post_id(thumb);
      if (id === null) {
        show_notice(console.error, '[addon error] invalid thumbnail id?!');
        return;
      }

      // use and update thumbnail_cache
      let thumbs = thumbnail_cache.get(id);
      if (thumbs === undefined) thumbs = [];

      const is_new = !thumbs.includes(thumb);

      if (is_new) thumbs.push(thumb);
      thumbnail_cache.set(id, thumbs);

      if (is_new) {
        if (config.show_speaker_icon || config.show_animated_icon || config.show_ratings_icon) {
          fix_thumbnail_css(thumb);
        }

        add_speaker_icon(thumb);
        add_ratings_icon(thumb);
        add_thumbnail_click_listener(thumb);

        if (!is_personal_post_page())
          fadeout_viewed_post(thumb, id);
      }
    }
  }

  // the site's CSS is different when logged out, this will somewhat equalize it
  // TODO remove/replace in the future
  function fix_thumbnail_css(thumb_span) {
    const img = thumb_span.querySelector('.preview');
    if (img === null) return;

    thumb_span.style.display = 'block';
    thumb_span.style.textAlign = 'center';
    thumb_span.style.float = 'left';
    img.style.display = 'inline';
    img.style.position = 'static';
  }

  function add_thumbnail_click_listener(thumb_span) {
    const a = thumb_span.getElementsByTagName('A');
    if (a.length === 0) return;

    a[0].addEventListener('click', (e) => {
      thumbnail_click_listener(e.currentTarget.parentElement);
    });
  }

  function add_speaker_icon(thumb_span) {
    if (!(config.show_speaker_icon || config.show_animated_icon)) return;

    const a = thumb_span.getElementsByTagName('A');
    if (a.length === 0) return;

    const tags = get_thumbnail_tags(thumb_span);
    if (tags === null) return;

    const icon = document.createElement('SPAN');
    if (config.show_speaker_icon && (tags.includes('has_audio'))) {
      icon.innerText = '🔊';
    } else if (config.show_animated_icon && (tags.includes('animated') || tags.includes('video'))) {
      icon.innerText = '⏩';
    } 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_ratings_icon(thumb_span) {
    if (!config.show_ratings_icon) return;

    const a = thumb_span.getElementsByTagName('A');
    if (a.length === 0) return;

    const tags = get_thumbnail_tags(thumb_span);
    if (tags === null) return;

    const icon = document.createElement('SPAN');
    if (tags.includes('Rating:Explicit')) {
      icon.innerText = 'E';
      icon.style.color = '#E00';
    } else if (tags.includes('Rating:Questionable')) {
      icon.innerText = 'Q';
      icon.style.color = '#666';
    } else if (tags.includes('Rating:Safe')) {
      icon.innerText = 'S';
      icon.style.color = '#4dc919';
    } else {
      return;
    }

    icon.className = 'ratings_icon';
    icon.style.position = 'absolute';
    icon.style.bottom = '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);
  }

  // TODO this isn't optimal when the thumbnail has low contrast to the background
  function fadeout_post(thumb_span) {
    if (!config.view_history_enabled) return;

    const links = thumb_span.getElementsByTagName('A');
    const images = thumb_span.getElementsByTagName('IMG');
    if (links.length !== 1 || images.length !== 1) {
      show_notice(console.error, '[addon error] found abnormal thumbnail?!', thumb_span);
      return;
    }

    // move box shadow from image to link, so opacity doesn't affect it
    links[0].style.display = 'inline-block';
    links[0].style.boxShadow = window.getComputedStyle(images[0]).getPropertyValue('box-shadow');
    images[0].style.boxShadow = '';

    images[0].style.opacity = '20%';
    for (const speaker_icon of thumb_span.getElementsByClassName('speaker_icon'))
      speaker_icon.style.opacity = '20%';
  }

  function fadeout_viewed_post(thumb_span, id) {
    if (config[HISTORY_KEY].has(id))
      fadeout_post(thumb_span);
  }

  function add_thumbnail_observer(predicate) {
    // don't hog CPU when disabled, but requires page reload to activate
    if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return;

    // this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded
    register_observer(predicate, (node, observer) => {
      modify_thumbnails(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.set_video_volume) node.volume = config.video_volume / 100.0;
    if (config.video_mute)       node.muted = true;
    node.controls = config.video_controls;
  }


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

  let called_add_mode_options = false;
  let call_postmodemenu_init_workaround = false;
  let cached_username = null;

  // won't work on any page
  function get_username() {
    if (cached_username !== null) return cached_username;

    // read from the 'My Favorites' button (in one of the subnavs) in the navbar
    const navbar = document.getElementById('navbar');
    if (navbar === null) return null;

    const fav_prefix = window.location.origin + '/?tags=fav%3A';

    for (const a of navbar.getElementsByTagName('A')) {
      if (typeof a.href === 'string' && a.href.startsWith(fav_prefix)) {
        cached_username = a.href.substring(fav_prefix.length);
        break;
      }
    }

    return cached_username;
  }

  // this won't be called when not logged in or when the user does not have tag script permission
  function add_mode_options() {
    if (called_add_mode_options) return;
    called_add_mode_options = true;

    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) {
      show_notice(console.log, '[addon error] add_mode_options() couldn\'t find mode dropdown?!');
      return;
    }

    // override change event
    mode_dropdown.removeAttribute('onchange');
    mode_dropdown.onchange = PostModeMenu_change_override;

    if (!IS_GREASEMONKEY4) {
      if (mode_dropdown.options.namedItem('choose-parent') === null) {
        const option = document.createElement('option');
        option.text  = 'Choose Parent';
        option.value = 'choose-parent';
        mode_dropdown.add(option);
      }

      if (mode_dropdown.options.namedItem('set-parent') === null) {
        const option = document.createElement('option');
        option.text  = 'Set Parent';
        option.value = 'set-parent';
        mode_dropdown.add(option);
      }

      if (mode_dropdown.options.namedItem('edit-tags') === null) {
        const option = document.createElement('option');
        option.text  = 'Edit Tags';
        option.value = 'edit-tags';
        mode_dropdown.add(option);
      }
    }

    // add_mode_options() was called late
    if (call_postmodemenu_init_workaround) {
      PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page
    }
  }

  async function add_post_edit_dialog() {
    const dialog = create_popup_menu();
    dialog.id = 'post_edit_dialog';
    dialog.style.borderWidth = '4px';

    // edit box, modified from the post page
    dialog.innerHTML =
`<div id="SA-edit" style="display: flex; align-items: center;">
<div style="width: 300px; height: 300px; display: flex; align-items: center; justify-content: center; margin-right: 12px">
<img id="SA-edit-image" src="${EMPTY_IMAGE}">
</div>
<div>
<form id="SA-edit-form" method="post" style="width: fit-content">
<input id="SA-post_old_tags" name="post[old_tags]" type="hidden" value="">
<table class="form">
<tfoot>
<tr>
<td colspan="2"><input accesskey="s" name="commit" tabindex="11" type="submit" value="Save changes" style="min-width: 13.5em;">
<button id="tag_reset_button" style="margin-left: 6px; cursor: pointer;">Reset</button>
<button id="post-link" style="cursor: pointer;">Open Post</button>
</td>
</tr>
</tfoot>
<tbody>
<tr>
<th style="width: 10%">
<label class="block" for="SA-post_rating_questionable">Rating</label>
</th>
<td style="width: 90%">
<input id="SA-post_rating_explicit" name="post[rating]" tabindex="1" type="radio" value="explicit">
<label for="SA-post_rating_explicit">R18+</label>
<input id="SA-post_rating_questionable" name="post[rating]" tabindex="2" type="radio" value="questionable">
<label for="SA-post_rating_questionable">R15+</label>
<input checked="" id="SA-post_rating_safe" name="post[rating]" tabindex="3" type="radio" value="safe">
<label for="SA-post_rating_safe">G</label>
</td>
</tr>
<tr>
<th>
<label class="block" for="post_tags">Tags</label>
</th>
<td>
<textarea cols="50" id="SA-post_tags" name="post[tags]" rows="9" spellcheck="false" tabindex="10" autocomplete="off"></textarea>
</td>
</tr>
</tbody>
</table>
</form>
</div>
</div>`;

    document.body.appendChild(dialog);

    document.getElementById('tag_reset_button').onclick = () => { reset_tags(); return false; };

    // hide when clicking outside
    document.addEventListener('click', (e) => {
      if (is_post_edit_dialog_visible()) {
        let el = e.target;
        while (el !== null) {
          if (['post_edit_dialog', 'autosuggest'].includes(el.id)) return; // clicked inside
          el = el.parentElement;
        }

        show_post_edit_dialog(false);
        e.preventDefault();
      }
    }, true);

    while (unsafeWindow.AutoSuggest === undefined)
      await sleep(250);

    unsafeWindow.AutoSuggest.add('#SA-post_tags');
  }

  function show_post_edit_dialog(bool) {
    const dialog = document.getElementById('post_edit_dialog');
    if (dialog !== null) dialog.style.display = (bool ? 'block' : 'none');
  }

  function is_post_edit_dialog_visible() {
    const dialog = document.getElementById('post_edit_dialog');
    return dialog !== null && dialog.style.display === 'block';
  }

  function open_post_edit_dialog(thumb_span) {
    const dialog = document.getElementById('post_edit_dialog');
    if (dialog === null) {
      show_notice(console.error, '[addon error] tag edit popup is missing?!');
      return;
    }

    const post_id = get_thumbnail_post_id(thumb_span);
    if (post_id === null) {
      show_notice(console.error, '[addon error] invalid thumbnail id?!');
      return;
    }

    const a = thumb_span.getElementsByTagName('A');
    if (a.length === 0) return;

    const edit_image = document.getElementById('SA-edit-image');
    const post_old_tags = document.getElementById('SA-post_old_tags');
    const post_tags = document.getElementById('SA-post_tags');
    const form = document.getElementById('SA-edit-form');
    const post_link = document.getElementById('post-link');

    // set thumbnail image and post link
    edit_image.src = EMPTY_IMAGE;
    edit_image.src = thumb_span.querySelector('.preview').src;
    post_link.onclick = () => {
      open_in_tab(a[0].href);
      return false;
    };

    const full_rating = (rating) => ({ s: 'safe', q: 'questionable', e: 'explicit' }[rating]);

    // get tags and rating (don't use get_thumbnail_tags() here because of possible browser extension conflicts)
    const post = unsafeWindow.Post.posts.get(post_id);
    const tags = post.tags.join(' ');
    const rating = full_rating(post.rating);

    // set edit box contents
    post_old_tags.value = tags;
    post_tags.value = tags;

    if (rating !== null) {
      document.getElementById('SA-post_rating_' + rating).checked = true;
    }

    form.addEventListener('submit', (event) => {
      event.preventDefault(); // block reloading page

      // manually submit data
      fetch(`/post/update/${post_id}`, {
        method: 'POST',
        body: new FormData(form),
        redirect: 'manual', // this will otherwise err because of a https -> http redirect
      })
      .then((result) => {
        // we assume success on redirect
        if (result.type === 'opaqueredirect' || result.ok) {
          show_post_edit_dialog(false);
        } else {
          show_notice(console.error, '[addon error] couldn\'t save tags!');
        }
      })
      .catch((error) => {
        show_notice(console.error, '[addon error] network error while saving tags!', error);
      });
    });

    show_post_edit_dialog(true);
  }

  function add_postmode_hotkeys() {
    document.addEventListener('keydown', (e) => {
      const mode_dropdown = document.getElementById('mode');
      if (mode_dropdown === null) return;
      if (e.ctrlKey || e.altKey || e.shiftKey) return;

      if (e.target === mode_dropdown) {
        e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts'
      } else {
        const tag = e.target.tagName.toLowerCase();
        if (tag === 'input' || tag === 'textarea' || tag === 'select') {
          return;
        }
      }

      const old_mode = mode_dropdown.value;

      switch (e.key) {
        case 'v':
          if (IS_GREASEMONKEY4) {
            show_notice(console.warn, '[addon] \'Set Parent\' not yet supported in Greasemonkey');
            return;
          }

          mode_dropdown.value = 'set-parent';
          break;
        case 'c':
          if (IS_GREASEMONKEY4) {
            show_notice(console.warn, '[addon] \'Choose Parent\' not yet supported in Greasemonkey');
            return;
          }

          mode_dropdown.value = 'choose-parent';
          break;
        case 'q':
          mode_dropdown.value = 'rating-q';
          break;
        case 's':
          mode_dropdown.value = 'rating-s';
          break;
        case 'e':
          mode_dropdown.value = 'rating-e';
          break;
        default:
          return;
      }

      // couldn't set mode (option doesn't exist)
      if (!mode_dropdown.value) mode_dropdown.value = old_mode;

      PostModeMenu_change_override();
    }, true);
  }

  function PostModeMenu_init_workaround() {
    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) {
      show_notice(console.error, '[addon error] PostModeMenu_init_workaround() couldn\'t find mode dropdown?!');
      return;
    }

    // issue: new post modes can reset on page load if they were added too late
    // reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then
    // calls PostModeMenu.change, which sets the cookie to mode_dropdown.value.
    // so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset
    // solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added
    const mode = get_cookie('addon_mode');
    if (mode !== '') {
      set_cookie('mode', mode, 7);
      mode_dropdown.value = mode;

      // setting mode failed, possible when changing to account with lower permissions
      if (!mode_dropdown.value) {
        show_notice(console.error, `[addon error] couldn't set mode to ${mode}, resetting to 'view'`);
        mode_dropdown.value = 'view';
        set_cookie('mode', 'view', 7);
      }
    }

    PostModeMenu_change_override();
  }

  async function PostModeMenu_change_override() {
    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) {
      show_notice(console.error, '[addon error] PostModeMenu_change_override() couldn\'t find mode dropdown?!');
      return;
    }

    if (!mode_dropdown.value) {
      show_notice(console.error, '[addon error] invalid mode, resetting to \'view\'');
      mode_dropdown.value = 'view';
      set_cookie('mode', 'view', 7);
    }

    // try to guarantee sitescript has loaded TODO: future note: potential infinite loop
    while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined)
      await sleep(100);

    unsafeWindow.PostModeMenu.change();

    const s = mode_dropdown.value;

    set_cookie('addon_mode', s, 7); // set 'backup' cookie

    const darkmode = is_darkmode();
    if (s === 'add-fav') {
      // FFFFAA, original. darkmode: luminance 40
      document.body.style.backgroundColor = (darkmode ? '#505000' : '#FFA');
    } else if (s === 'remove-fav') {
      // FFFFAA -> FFEEAA, slightly more orange. darkmode: luminance 40
      document.body.style.backgroundColor = (darkmode ? '#504000' : '#FEA');
    } else if (s === 'apply-tag-script') {
      // AA33AA -> FFDDFF, weaken color intensity. darkmode: luminance 40
      document.body.style.backgroundColor = (darkmode ? '#500050' : '#FDF');
    } else if (s === 'approve') {
      // 2266AA -> FFDDFF, increase contrast to unapproved posts. darkmode: TODO
      document.body.style.backgroundColor = '#FDF';
    } else if (s === 'choose-parent') {
      document.body.style.backgroundColor = (darkmode ? '#464600' : '#FFD');
    } else if (s === 'set-parent') {
      if (get_cookie('chosen-parent') === '') {
        show_notice(console.warn, '[addon] Choose parent first!');
        mode_dropdown.value = 'choose-parent';
        PostModeMenu_change_override();
      } else {
        document.body.style.backgroundColor = (darkmode ? '#005050' : '#DFF');
      }
    } else if (s === 'edit-tags') {
      document.body.style.backgroundColor = get_original_background_color();
    }
  }

  let PostModeMenu_click_original = null;
  function PostModeMenu_click_override(post_id) {
    if (PostModeMenu_click_original(post_id))
      return true; // view mode, let it click

    if (!called_add_mode_options) return false; // not logged in or no tag script permission

    const mode_dropdown = document.getElementById('mode');
    const s = mode_dropdown.value;

    if (s === 'choose-parent') {
      set_cookie('chosen-parent', post_id);
      mode_dropdown.value = 'set-parent';
      PostModeMenu_change_override();
    } else if (s === 'set-parent') {
      const parent_id = get_cookie('chosen-parent');
      unsafeWindow.TagScript.run(post_id, 'parent:' + parent_id + (config.setparent_deletepotentialduplicate ? ' -potential_duplicate' : ''));
    }

    return false;
  }

  function thumbnail_click_listener(thumb) {
    if (!called_add_mode_options) return; // not logged in or no tag script permission

    const mode_dropdown = document.getElementById('mode');
    const s = mode_dropdown.value;

    // edit-tags operates on thumbnail, not post ids (original click override will do nothing)
    if (s === 'edit-tags') {
      open_post_edit_dialog(thumb);
    }
  }

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

  let post_parent_id = null; // input elem
  // original post/parent ids
  let post_id = null;
  let parent_id = null;
  let image_data = null;
  let resize_timer;
  let tag_update_timer;
  let tags_changed = false;
  // set by find_actions_list():
  let found_delete_action = false;

  let done_scrolling = false;
  function is_done_scrolling() {
    if (!config.scroll_to_image) return true;
    return done_scrolling;
  }

  let mouse_moved = false; // for tag_menu_scaler
  function tag_menu_scaler_mousedown(e) {
    e.preventDefault();
    mouse_moved = false;
    window.addEventListener('mousemove', tag_menu_scaler_mousemove);
    window.addEventListener('mouseup',   tag_menu_scaler_mouseup);
  }

  function tag_menu_scaler_mousemove(e) {
    e.preventDefault();
    mouse_moved = true;
    set_tag_menu_scale(e, false);
  }

  function tag_menu_scaler_mouseup(e) {
    e.preventDefault();
    if (mouse_moved) set_tag_menu_scale(e, true);

    window.removeEventListener('mousemove', tag_menu_scaler_mousemove);
    window.removeEventListener('mouseup',   tag_menu_scaler_mouseup);
  }

  function set_tag_menu_scale(e, save) {
    const tag_menu = document.getElementById('tag_menu');
    if (tag_menu === null) return;

    const yFromBottom = window.innerHeight - e.clientY;
    let yPercentfromBottom = (100.0 * (yFromBottom / window.innerHeight));
    yPercentfromBottom = Math.min(Math.max(yPercentfromBottom, 5), 95) + '%';

    tag_menu.style.height = yPercentfromBottom;

    if (save) save_setting('tag_menu_scale', yPercentfromBottom);
  }


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

    const tag_menu = document.createElement('DIV');
    tag_menu.id = 'tag_menu';
    tag_menu.style.display = 'none';
    tag_menu.style.width = '100%';
    tag_menu.style.height = config.tag_menu_scale;
    tag_menu.style.position = 'fixed';
    tag_menu.style.bottom = '0';
    tag_menu.style.overflow = 'auto';
    tag_menu.style.backgroundColor = get_original_background_color();
    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: calc(100% - 2px); height: 100%; overflow: auto;"><span id="common_tags"></span>current tags:<span id="current_tags"></span></div>';

    const tag_menu_scaler = document.createElement('DIV');
    tag_menu_scaler.id = 'tag_menu_scaler';
    tag_menu_scaler.style.width = '100%';
    tag_menu_scaler.style.height = '6px';
    tag_menu_scaler.style.backgroundColor = shifted_backgroundColor(32);
    tag_menu_scaler.style.position = 'absolute';
    tag_menu_scaler.style.top = '0';
    tag_menu_scaler.style.cursor = 'ns-resize';
    tag_menu_scaler.style.zIndex = '10000';
    tag_menu_scaler.addEventListener('mousedown', tag_menu_scaler_mousedown);
    tag_menu.appendChild(tag_menu_scaler);
    tag_menu.style.paddingTop = tag_menu_scaler.style.height; // since tag_menu_scaler floats above the tags

    const create_tag_menu_button = function(id, text) {
      const button = document.createElement('DIV');
      button.id = id;
      button.style.border = '1px solid ' + shifted_backgroundColor(32);
      button.style.width  = '24px';
      button.style.height = '24px';
      button.style.position = 'absolute';
      button.style.textAlign = 'center';
      button.style.cursor = 'pointer';
      button.style.backgroundColor = shifted_backgroundColor(16);
      button.innerHTML = `<span style="width: 100%; display: block; position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);">${text}</span>`;
      button.style.zIndex = '10001';
      return button;
    };

    const tag_menu_close = create_tag_menu_button('tag_menu_close', 'X');
    tag_menu_close.style.top   = '0';
    tag_menu_close.style.right = '0';
    tag_menu_close.onclick = () => { show_tag_menu(false); return false; };
    tag_menu.appendChild(tag_menu_close);

    const tag_menu_open = create_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 = () => { show_tag_menu(true); update_tag_menu(); return false; };
    document.body.appendChild(tag_menu_open);

    const tag_menu_save = create_tag_menu_button('tag_menu_save', 'Save changes');
    tag_menu_save.style.top   = '0';
    tag_menu_save.style.right = '36px';
    tag_menu_save.style.width = '140px';
    tag_menu_save.style.fontWeight = 'bold';
    tag_menu_save.addEventListener('click', (e) => {
      e.preventDefault();
      if (tags_submit_listener())
        document.getElementById('edit-form').submit();
    });
    tag_menu.appendChild(tag_menu_save);
  }

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

    const common_tags_elem  = document.getElementById('common_tags');
    const current_tags_elem = document.getElementById('current_tags');

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

    if (config.tag_menu_layout === 1) {
      common_tags_elem.style.display = 'grid';
      common_tags_elem.style.gridTemplateColumns = 'fit-content(5%) auto';
    } else {
      common_tags_elem.style.display = '';
    }

    const create_tag_button = function(tag, skip_common_tags_update = false) {
      const a = document.createElement('A');
      a.href = '#';
      a.style.paddingLeft  = '5px';
      a.style.paddingRight = '5px';
      a.style.borderStyle = 'solid';
      a.style.borderWidth = '1px';
      a.style.backgroundColor = (is_darkmode() ? '#000' : '#FFF'); // more contrast for tag buttons
      a.className = (tag_is_present(tag) ? '' : 'tag_nonexistent');

      a.onclick = function(e) {
        if (e.ctrlKey) {
          open_in_tab(window.location.origin + '/wiki/show/?title=' + tag);
          return false;
        }

        if (tag_is_present(tag)) {
          remove_tag(tag, skip_common_tags_update);
          a.className = 'tag_nonexistent';
        } else {
          add_tag(tag, skip_common_tags_update);
          a.className = '';
        }
        return false;
      };
      a.innerText = tag;
      return a;
    };

    const wrap_in_div = function(el, margin) {
      const div = document.createElement('DIV');
      div.style.margin = margin;
      div.style.float = 'left';
      div.appendChild(el);
      return div;
    };

    const create_top_level_div = function(margin = '3px') {
      const div = document.createElement('DIV');
      div.style.margin = margin;
      return div;
    };

    const create_tag_list = function() {
      const div = document.createElement('DIV');
      div.style.display = 'flex';
      div.style.flexWrap = 'wrap';
      div.style.alignContent = 'flex-start';
      div.style.alignItems = 'flex-start';
      div.style.margin = '0';
      div.style.padding = '0';
      return div;
    };

    // generate tag button list for current tags
    const current_tags_flex = create_tag_list();
    current_tags_flex.style.marginBottom = '3px';
    const current_tags = get_tags_array();
    for (const current_tag of current_tags) {
      const div = create_top_level_div();
      div.appendChild(create_tag_button(current_tag, false));
      current_tags_flex.appendChild(div);
    }

    // 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_flex);

    // don't rebuild the common tags list when common tags buttons are pressed
    if (skip_common_tags) return;

    // now add common tags
    // common_tags_json(_idol) should be an array of objects with an optional string "name" field and an array "tags" field,
    // where the "tags" array can contain strings (space separated tags), arrays containing one string (representing a group)
    // or arrays of array containing one string (representing a table)
    // ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ]
    let tag_data;
    try {
      tag_data = JSON.parse(config[COMMON_TAGS_KEY]);
    } catch (error) {
      show_notice(console.error, '[addon error] "common tags" JSON syntax error', error);
      return;
    }

    if (!Array.isArray(tag_data)) {
      show_notice(console.error, '[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 (let k = 0; k < tag_data.length; k++) {
      const list_flex = create_tag_list();
      const list_name = tag_data[k].name;
      const list_tags = tag_data[k].tags;

      if (!Array.isArray(list_tags)) {
        show_notice(console.error, '[addon error] a "common tags" object needs to have a "tags" array');
        return;
      }

      const TAGS_TYPES = {
        LIST:  'list',  // e.g. "tag1 tag2"
        GROUP: 'group', // e.g. ["tag1 tag2"]
        TABLE: 'table'  // e.g. [["tag1 tag2"], ["tag3 tag4"]]
      };

      const group_style = function(el) {
        // red in darkmode needs more contrast
        const rgb = rgb_to_array(get_original_background_color());
        if (is_darkmode()) {
          el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, 96));
          el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, 64));
        } else {
          el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, -64));
          el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, -32));
        }
      };

      for (const list_tag of list_tags) {
        const is_array = Array.isArray(list_tag);

        // find tags_type
        let tags_type;
        if (is_array) {
          if (list_tag.length === 0) {
            show_notice(console.error, '[addon error] "common tags" "tags" array contains an empty array');
            return;
          }

          // check what the array consists of
          let all_arrays = true;
          let no_arrays = true;
          for (let i = 0; i < list_tag.length; i++) {
            if (!Array.isArray(list_tag[i])) {
              all_arrays = false;
            } else {
              no_arrays = false;
            }
          }

          if (all_arrays) {
            tags_type = TAGS_TYPES.TABLE;
          } else if (no_arrays) {
            tags_type = TAGS_TYPES.GROUP;
          } else {
            show_notice(console.error, '[addon error] "common tags" "tags" array contains an array which is neither a group nor a table');
            return;
          }
        } else {
          tags_type = TAGS_TYPES.LIST;
        }

        if (tags_type === TAGS_TYPES.TABLE) {
          const tags_table = [];
          for (let j = 0; j < list_tag.length; j++) {
            if (list_tag[j].length !== 1) {
              show_notice(console.error, '[addon error] "common tags" "tags" array contains a table entry with not exactly 1 tags string');
              return;
            }

            tags_table.push(list_tag[j][0].trim().split(/\s+/));
          }

          const table_height = tags_table.length;
          let table_width = 0;
          for (let row = 0; row < tags_table.length; row++)
            table_width = Math.max(table_width, tags_table[row].length);

          // div (flexbox)><div><table><tr><td><div (button)>
          const table = document.createElement('TABLE');
          table.style.display = 'inline-block';
          group_style(table);
          table.style.marginBottom = '0';
          for (let row = 0; row < table_height; row++) {
            const tr = document.createElement('TR');
            for (let col = 0; col < table_width; col++) {
              const td = document.createElement('TD');
              td.style.border = 'none';
              td.style.padding = '0';
              if (tags_table[row][col])
                td.appendChild(wrap_in_div(create_tag_button(tags_table[row][col], true), '1px'));
              tr.appendChild(td);
            }
            table.appendChild(tr);
          }

          const div = create_top_level_div('0 3px 0 3px');
          div.appendChild(table);
          list_flex.appendChild(div);
        } else if (tags_type === TAGS_TYPES.GROUP) {
          if (list_tag.length !== 1) {
            show_notice(console.error, '[addon error] "common tags" "tags" array contains a group with not exactly 1 tags string');
            return;
          }

          const tags = list_tag[0].trim().split(/\s+/);

          // <div (flexbox)><div><div (button)>
          const group_div = document.createElement('DIV');
          group_div.style.display = 'inline-block';
          group_style(group_div);

          for (const tag of tags)
            group_div.appendChild(wrap_in_div(create_tag_button(tag, true), '3px'));

          const div = create_top_level_div('0 3px 0 3px');
          div.appendChild(group_div);
          list_flex.appendChild(div);
        } else /* if (tags_type === tag_types.LIST) */ {
          // <div (flexbox)><div><div (button)>
          const tags = list_tag.trim().split(/\s+/);
          for (const tag of tags) {
            const div = create_top_level_div('4px 3px 2px 3px');
            div.appendChild(wrap_in_div(create_tag_button(tag, true)));
            list_flex.appendChild(div);
          }
        }
      }

      const span = document.createElement('SPAN');
      span.innerText = (list_name ? `${list_name}:` : '');
      span.style.paddingTop = '2px';
      if (list_name) span.style.marginLeft = '2px';

      if (list_name && config.tag_menu_layout === 1) {
        const add_top_border = function(el) {
          el.style.borderTopWidth = '1px';
          el.style.borderTopStyle = 'solid';
          el.style.borderTopColor = shifted_backgroundColor(32);
        };
        add_top_border(span);
        add_top_border(list_flex);
      }

      common_tags_elem.appendChild(span);
      common_tags_elem.appendChild(list_flex);
    }
  }

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

  function add_tags_change_listener() {
    const post_tags_area = document.getElementById('post_tags');
    if (post_tags_area === null) return; // not logged in

    post_tags_area.addEventListener('input', () => {
      tags_changed = true;
      clearTimeout(tag_update_timer);
      tag_update_timer = setTimeout(update_tag_elements, 500);
    });
  }

  // also used for 'tag_menu_save' button
  function tags_submit_listener() {
    delete_useless_tags_tag();
    return true; // actually submit?
  }

  function add_tags_submit_listener() {
    const edit_form = document.getElementById('edit-form');
    if (edit_form === null) return; // not logged in

    edit_form.addEventListener('submit', (e) => {
      if (!tags_submit_listener())
        e.preventDefault();
    });
  }

  async function find_actions_list() {
    // TODO: stop trying at some point (readyState === 'complete'?)
    let li;
    while ((li = document.getElementById('add-to-pool')) === null)
      await sleep(200);

    let actions_ul;
    while ((actions_ul = li.parentElement) === null)
      await sleep(100);

    const action_links = actions_ul.getElementsByTagName('A');
    for (const action_link of action_links) {
      if (action_link.href.includes('/delete/')) {
        found_delete_action = true;
        break;
      }
    }

    return actions_ul;
  }

  function add_addon_actions(actions_ul) {
    if (actions_ul === null) {
      show_notice(console.error, '[addon error] couldn\'t find actions list! Disabled addon actions.');
      return;
    }

    const separator = document.createElement('H5');
    separator.innerText = 'Addon actions';
    const newli = document.createElement('LI');
    newli.appendChild(separator);
    actions_ul.appendChild(newli);

    const add_action = function(func, name, id) {
      const a = document.createElement('A');
      a.href = '#';
      a.onclick = () => { func(); return false; };
      a.innerText = name;

      const li = document.createElement('LI');
      li.id = id;
      li.appendChild(a);
      actions_ul.appendChild(li);
    };

    add_action(() => { scale_image(SCALE_MODES.FIT,        true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image',              'scale-image-fit');
    add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Horizontal)', 'scale-image-hor');
    add_action(() => { scale_image(SCALE_MODES.VERTICAL,   true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Vertical)',   'scale-image-ver');
    add_action(() => { scale_image(SCALE_MODES.RESET,      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) {
      show_notice(console.error, '[addon error] couldn\'t find post id?! Flag duplicate feature disabled.');
      return;
    }

    add_action(() => { flag_duplicate(post_id, '');                       }, 'Flag duplicate',              'flag-duplicate');
    add_action(() => { flag_duplicate(post_id, ', visually identical');   }, 'Flag duplicate (identical)',  'flag-duplicate-identical');
    add_action(() => { flag_duplicate(post_id, ' with worse quality');    }, 'Flag duplicate (quality)',    'flag-duplicate-quality');
    add_action(() => { flag_duplicate(post_id, ' with worse resolution'); }, 'Flag duplicate (resolution)', 'flag-duplicate-resolution');
  }

  function add_filetype_stat() {
    try {
      const img_link = document.getElementById('highres');
      if (img_link === null) return; // flash?

      const s = new URL(img_link.href).pathname.split('.');
      const ext = s[s.length - 1];

      const filetype = document.createTextNode(`File type: ${ext}`);
      const li = document.createElement('LI');
      li.appendChild(filetype);
      insert_node_after(li, img_link.parentNode);

    } catch (error) {
      console.error('[addon error] add_filetype_stat failed with', error);
    }
  }

  function move_stats_to_edit_form() {
    try {

      // form display: flex
      // stats insertafter table
      // stats white-space: nowrap
      // #edit-form width: max-content
      // margin-left: 8px;

      const stats = document.getElementById('stats');

      const edit_form = document.getElementById('edit-form');
      if (edit_form === null) return;

      const table = edit_form.getElementsByTagName('TABLE')[0];

      edit_form.style.display = 'flex'; // add to the right

      insert_node_after(stats, table);
      stats.style.whiteSpace = 'nowrap';
      edit_form.style.width = 'max-content';
      stats.style.marginLeft = '8px';
    } catch (error) {
      console.error('[addon error] move_stats_to_edit_form failed with', error);
    }
  }

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

    const button_place = edit_form.querySelector('table > tfoot > tr > td');
    button_place.style.whiteSpace = 'nowrap';
    // min-width is 25% by default, which makes the <td> not fit its content
    button_place.children[0].style.minWidth = '13.5em';

    {
      const el = create_button();
      el.id = 'clear_parent_id_button';
      el.style.margin = '0 3px 0 6px';
      el.innerText = 'Clear';
      el.onclick = () => { post_parent_id.clear(); return false; };
      post_parent_id.parentNode.appendChild(el);
    }

    {
      const el = create_button();
      el.id = 'reset_parent_id_button';
      el.style.margin = '0 3px';
      el.innerText = 'Reset';
      el.onclick = () => { reset_parent_id(); return false; };
      post_parent_id.parentNode.appendChild(el);
    }

    {
      const el = create_button();
      el.id = 'tag_reset_button';
      el.style.margin = '0 3px 0 6px';
      el.innerText = 'Reset';
      el.onclick = () => { reset_tags(); return false; };
      button_place.appendChild(el);
    }

    {
      const el = create_button();
      el.id = 'tag_dup_button';
      el.style.margin = '0 3px';
      button_place.appendChild(el);
    }

    {
      const el = create_button();
      el.id = 'tag_var_button';
      el.style.margin = '0 3px';
      button_place.appendChild(el);
    }

    {
      const el = create_button();
      el.id = 'tag_pot_button';
      el.style.margin = '0 3px';
      button_place.appendChild(el);
    }
  }

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

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

    const tags = get_tags_array();

    if (!tags.includes('duplicate')) {
      dup_button.onclick = function() { add_tag('duplicate'); return false; };
      dup_button.innerText = 'Tag duplicate';
    } else {
      dup_button.onclick = function() { remove_tag('duplicate'); return false; };
      dup_button.innerText = 'Untag duplicate';
    }

    if (!tags.includes('legitimate_variation')) {
      var_button.onclick = function() { add_tag('legitimate_variation'); return false; };
      var_button.innerText = 'Tag legitimate_variation';
    } else {
      var_button.onclick = function() { remove_tag('legitimate_variation'); return false; };
      var_button.innerText = 'Untag legitimate_variation';
    }

    pot_button.innerText = 'Untag potential_duplicate';
    if (!tags.includes('potential_duplicate')) {
      pot_button.disabled = true;
      pot_button.style.removeProperty('cursor');
    } else {
      pot_button.onclick = function() { remove_tag('potential_duplicate'); return false; };
      pot_button.disabled = false;
      pot_button.style.cursor = 'pointer';
    }
  }

  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, skip_common_tags_update = false) {
    const tags = get_tags_array();

    if ((tag === 'duplicate' && tags.includes('legitimate_variation')) || (tag === 'legitimate_variation' && tags.includes('duplicate'))) {
      show_notice(console.warn, '[addon] cannot tag as duplicate and legitimate_variation at the same time.');
      return;
    }

    if (tags.includes(tag)) {
      show_notice(console.warn, '[addon] tag already present');
      return;
    }

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

    tags_changed = true;
    update_tag_elements(skip_common_tags_update);
  }

  function remove_tag(tag, skip_common_tags_update = false) {
    const tags = get_tags_array();

    for (let i = 0; i < tags.length; i++)
      if (tags[i] === tag)
        tags[i] = '';

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

    tags_changed = true;
    update_tag_elements(skip_common_tags_update);
  }

  function delete_useless_tags_tag() {
    if (tags_changed && config.editform_deleteuselesstags) remove_tag('useless_tags');
  }

  function tag_is_present(tag) {
    return get_tags_array().includes(tag);
  }

  function reset_tags() {
    document.getElementById('post_tags').value = document.getElementById('post_old_tags').value;
    tags_changed = false;
    update_tag_elements();
  }

  function update_tag_elements(skip_common_tags = false) {
    update_tag_buttons();

    const tag_menu = document.getElementById('tag_menu');
    if (tag_menu !== null && tag_menu.style.display !== 'none') {
      update_tag_menu(skip_common_tags);
    }
  }



  // flag option with default text
  function flag_duplicate(id, reason_suffix) {
    if (IS_GREASEMONKEY4) {
      show_notice(console.warn, '[addon] \'Flag duplicate\' not yet supported in Greasemonkey');
      return;
    }

    if (parent_id === null) {
      show_notice(console.warn, '[addon] parent id not found, not logged in?');
      return;
    }

    const current_parent_id = post_parent_id.value;
    if (current_parent_id !== parent_id) {
      show_notice(console.warn, '[addon] parent id was changed but not saved!');
      return;
    }

    if (!current_parent_id || current_parent_id.length === 0) {
      show_notice(console.warn, '[addon] no parent id set!');
      return;
    }

    const tags = get_tags_array();
    const old_tags = get_old_tags_array();
    if (tags.includes('duplicate') && !old_tags.includes('duplicate')) {
      show_notice(console.warn, '[addon] duplicate tag set but not saved!');
      return;
    }
    if (!old_tags.includes('duplicate')) {
      show_notice(console.warn, '[addon] not tagged as duplicate!');
      return;
    }

    if (old_tags.includes('legitimate_variation') || old_tags.includes('revision'))
      if (!window.confirm('Post is tagged as a legitimate_variation or revision, it may not be a duplicate!\n\nFlag it anyway?'))
        return;

    const reason = window.prompt('Why should this post be reconsidered for moderation?', `duplicate of ${parent_id}${reason_suffix}`);
    if (reason === null)
      return;

    // TODO will not work on Greasemonkey at all, should probably replace with fetch()
    new unsafeWindow.Ajax.Request('/post/flag.json', {
      parameters: { id, reason },
      onComplete(response) {
        const resp = response.responseJSON;
        if (resp.success) show_notice(console.log, 'Post was resent to moderation queue');
        else              show_notice(console.error, `Error: ${resp.reason}`);
      },
    });
  }

  // writes to image_data once finished
  async function read_image_data() {
    const data = {
      img_elem: null, // <img>, <video> or <object> (in case of flash)
      emb_elem: null, non_img_div: null, // flash is <object><embed>, we need the <div> it's in as well
      is_flash: false,
      width: null,
      height: null,
      aspect_ratio: null,
      current_height: null, // store current height separately, because flash is a bitch
    };

    // image or video
    const img = document.getElementById('image');
    if (img !== null) {
      data.img_elem = img;

      // the href of this element is removed when the original image is loaded
      const sample_link = document.querySelector('a#image-link.sample');
      const is_sample = sample_link !== null && sample_link.hasAttribute('href');

      let res = null;
      if (is_sample) {
        const lowres = document.getElementById('lowres');
        if (lowres !== null) {
          res = lowres.innerText.split('x'); // parse "<width>x<height>"
        }
      } else {
        const highres = document.getElementById('highres');
        if (highres !== null) {
          res = highres.innerText.split(' ')[0].split('x'); // parse "<width>x<height> (<file size>)"
        }

        if (res === null) {
          if (img.hasAttribute('orig_width') && img.hasAttribute('orig_height')) {
            res = [img.getAttribute('orig_width'), img.getAttribute('orig_height')];
          }
        }
      }

      if (res === null) {
        console.log('[addon] Couldn\'t read resolution from details section, waiting for image size...');

        // last resort: try to read natural size
        // when loading the original image, this can read the old preview size instead, which shouldn't be a huge deal since
        // this happens after image scrolling (see is_done_scrolling()) and the aspect ratio is approximately correct
        // TODO: this will however break the manual image scrolling

        // TODO: should abort when content failed loading
        while ((res = get_resolution(img)) === null)
          await sleep(20);
      }

      data.width  = Number(res[0]);
      data.height = Number(res[1]);
      console.log('[addon] Read image or video resolution ', data.width, 'x', data.height);

      data.current_height = data.height;
      data.aspect_ratio = data.width / data.height;
      image_data = data;
      return;
    }

    // flash or unknown
    const non_img = document.getElementById('non-image-content');
    if (non_img !== null) {
      data.non_img_div = non_img;

      const objs = non_img.getElementsByTagName('OBJECT');
      const embs = non_img.getElementsByTagName('EMBED');
      data.is_flash = (objs.length === 1 && embs.length === 1); // <object><embed>

      if (!data.is_flash) {
        show_notice(console.error, '[addon error] unknown post content! Can\'t read width/height.');
        return;
      }

      data.img_elem = objs[0];
      data.emb_elem = embs[0];
      // <object> contains width/height in both Firefox and Chrome
      data.width  = data.img_elem.width;
      data.height = data.img_elem.height;
      console.log('[addon] Read resolution ', data.width, 'x', data.height);

      data.current_height = data.height;
      data.aspect_ratio = data.width / data.height;
      image_data = data;
    }
  }

  const SCALE_MODES = { RESET: -1, FIT: 0, HORIZONTAL: 1, VERTICAL: 2 };

  // stretch image/video/flash, requires data from read_image_data()
  function scale_image(mode, always_scale) {
    if (image_data === null) return; // read_image_data() failed

    if (!always_scale && (!config.scale_flash && image_data.is_flash))
      return;

    // We can't use transform scale because it doesn't change the DOM size (so the image could be covered up by other elements)
    // We also can't use style.width/height because translation notes rely on .width/.height
    const set_dimensions = (obj, dim) => {
      obj.width  = dim.width;
      obj.height = dim.height;
    };

    // reset image size
    if (mode === SCALE_MODES.RESET) {
      if (!image_data.is_flash) {
        set_dimensions(image_data.img_elem, image_data);
        adjust_notes();
      } else {
        set_dimensions(image_data.img_elem, image_data);
        set_dimensions(image_data.emb_elem, image_data);
      }
      image_data.current_height = image_data.height;

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

      return;
    }

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

    const target_w = Math.max(window.innerWidth - left_side - get_scrollbar_width(), 1);
    const target_h = Math.max(window.innerHeight, 1);
    const target_aspect_ratio = target_w / target_h;

    if (mode === SCALE_MODES.FIT)
      mode = (image_data.aspect_ratio > target_aspect_ratio ? SCALE_MODES.HORIZONTAL : SCALE_MODES.VERTICAL);

    const scaled = {};
    if (mode === SCALE_MODES.HORIZONTAL) {
      scaled.width  = Math.floor(target_w);
      scaled.height = Math.floor(target_w / image_data.aspect_ratio);
    } else if (mode === SCALE_MODES.VERTICAL) {
      scaled.width  = Math.floor(target_h * image_data.aspect_ratio);
      scaled.height = Math.floor(target_h);
    }

    if (!always_scale && (config.scale_only_downscale && (scaled.width > image_data.width || scaled.height > image_data.height)))
      return;

    if (!image_data.is_flash) {
      set_dimensions(image_data.img_elem, scaled);
      adjust_notes();
    } else {
      set_dimensions(image_data.img_elem, scaled);
      set_dimensions(image_data.emb_elem, scaled);
    }

    image_data.current_height = scaled.height;
  }

  function adjust_notes() {
    if (unsafeWindow.Note) {
      for (const note of unsafeWindow.Note.all) {
        note.adjustScale(); // this relies on the image's .width and .height
      }
    }
  }

  function scale_on_resize_helper() {
    clearTimeout(resize_timer);
    resize_timer = setTimeout(() => {
      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) {
    window.requestAnimationFrame(() => {
      if (image_data === null) return;
      const absolute_img_top = (image_data.is_flash ? image_data.non_img_div : image_data.img_elem).getBoundingClientRect().top + window.scrollY;
      if (to_center) {
        const top_of_centered_rect = absolute_img_top - (window.innerHeight - image_data.current_height) / 2;
        window.scrollTo(0, top_of_centered_rect);
      } else {
        window.scrollTo(0, absolute_img_top);
      }
      done_scrolling = true;
    });
  }

  // when resize notice is hidden (e.g. original image is loaded), scroll to make up the difference
  function add_resize_notice_listener() {
    const resized_notice = document.getElementById('resized_notice');
    if (image_data === null || resized_notice === null) return;

    const notice_y_diff = image_data.img_elem.getBoundingClientRect().top - resized_notice.getBoundingClientRect().top;

    const observer = new MutationObserver(() => {
      observer.disconnect();
      window.scrollBy(0, -notice_y_diff);
    });
    observer.observe(resized_notice, {
      attributes: true,
      attributeFilter: ['style'],
    });
  }

  function add_highres_listener() {
    const img = document.getElementById('image');
    if (img === null) return;

    const observer = new MutationObserver(() => {
      // image is cleared before highres image is loaded
      if (img.src === 'about:blank') return;
      observer.disconnect();

      // re-read image size in case get_resolution() had to be used
      read_image_data().then(() => {
        if (config.scale_image) scale_image(config.scale_mode, false);
      });
    });

    observer.observe(img, {
      attributes: true,
      attributeFilter: ['src'],
    });
  }

  async function load_highres() {
    if (!config.load_highres) return;

    if (config.highres_limit > 0) {
      const highres = document.getElementById('highres');
      if (highres !== null) {
        let size = highres.title; // e.g. "1,738,253 bytes"
        size = size.slice(0, -' bytes'.length);
        size = size.replaceAll(',', '');
        size = parseInt(size, 10);

        if (size > config.highres_limit) {
          return;
        }
      }
    }

    while (!is_done_scrolling() || unsafeWindow.jQuery === undefined || unsafeWindow.Post === undefined || unsafeWindow.Post.highres === undefined)
      await sleep(20);

    const sample_link = unsafeWindow.jQuery('a#image-link.sample');
    if (sample_link.length === 0) return;

    sample_link.unbind('click').removeAttr('href');
    unsafeWindow.Post.highres();
  }

  // simple note fix for Galinoa's Sankaku Channel Dark (only for default image size)
  function note_fix() {
    if (!config.sankaku_channel_dark_compatibility) return;

    const note_container = document.getElementById('note-container');
    if (note_container !== null && image_data !== null) {
      note_container.style.marginLeft = ((window.innerWidth - image_data.img_elem.clientWidth) / 2 - 8) + 'px';
    }
  }

  function add_postpage_hotkeys() {
    document.addEventListener('keydown', (e) => {
      const tag = e.target.tagName.toLowerCase();
      if (tag === 'input' || tag === 'textarea' || tag === 'select') return;

      const hotkey = config.postpage_hotkeys[e.key.toLowerCase()];
      if (hotkey !== undefined) {
        hotkey.call(e);
      }
    }, true);
  }

  // sitefix: fix pixiv source links under 'Details'
  // issue: source links of the form https://www.pixiv.net/artworks/<id> turn into
  // https://www.pixiv.net/artworks/https://www.pixiv.net/artworks/<id>
  // doesn't happen for https://www.pixiv.net/en/artworks/<id>
  // or the old format https://www.pixiv.net/member_illust.php?mode=medium&illust_id=<id>
  function fix_pixiv_source_link() {
    const stats = document.getElementById('stats');
    if (stats === null) return;
    for (const link of stats.getElementsByTagName('A')) {
      if (link.href && link.href.startsWith('https://www.pixiv.net/artworks/')) {
        const id = link.href.substring('https://www.pixiv.net/artworks/'.length);
        try {
          new URL(id); // throws if not a valid URL
          link.href = id;
        } catch (ignore) { }
      }
    }
  }

  /***********************/
  /* wiki page functions */
  /***********************/

  function add_dtext_style_buttons() {
    const wiki_form = document.getElementById('wiki-form');
    const wiki_body = document.getElementById('wiki_page_body');

    if (wiki_form === null || wiki_body === null) {
      show_notice(console.error, '[addon error] couldn\'t find "wiki-form" or "wiki_page_body", DText style buttons disabled');
      return;
    }

    wiki_form.style.display = 'flex'; // add buttons to the right

    const div = document.createElement('DIV');
    div.style.marginLeft = '1em';
    div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top) + 'px';
    div.style.background = get_original_background_color();

    div.innerHTML = '<label title="Buttons add (or remove outermost) DText styling on selected text.\n'
      + 'Buttons have accesskeys/keyboard shortcuts, typically activated by Alt + Shift + key or Alt + key depending on the browser."'
      + ' style="cursor:help;">DText style buttons</label><br>';

    const DTEXT_CODES = [
      'b', 'i', 's', 'u', '<br>',
      'code', 'quote', 'spoiler', '<br>',
      '[[...]]', '{{...}}', 'URL',
    ];

    for (const code of DTEXT_CODES) {
      if (code === '<br>') {
        div.appendChild(document.createElement('BR'));
        continue;
      }

      const btn = create_button();
      btn.onclick = () => {
        const text = wiki_body.value;
        const a = wiki_body.selectionStart;
        const b = wiki_body.selectionEnd;
        const selection = text.substring(a, b);

        let cut_a = a, cut_b = b;         // range to cut
        let insert;                       // text to insert inbetween
        let sel_off_a = 0, sel_off_b = 0; // by default the inserted text is selected, these are offsets

        if (code === 'URL') {
          const link = window.prompt('Enter URL');
          if (link === null) return false;
          insert = `"${selection}":${link}`;
        } else {
          let open  = '';
          let close = '';
          if (code === '{{...}}') {
            open  = '{{';
            close = '}}';
          } else if (code === '[[...]]') {
            open  = '[[';
            close = ']]';
          } else {
            open  = `[${code}]`;
            close = `[/${code}]`;
          }

          const ext_a = Math.max(0, a - open.length);
          const ext_b = b + close.length;
          const ext_selection = text.substring(ext_a, ext_b);

          if (ext_selection.startsWith(open) && ext_selection.endsWith(close)) {
            // remove DText directly outside selection
            insert = selection;
            cut_a = ext_a;
            cut_b = ext_b;
            sel_off_a = -open.length;
          } else if (selection.startsWith(open) && selection.endsWith(close)) {
            // remove DText directly inside selection
            insert = selection.slice(open.length, -close.length);
          } else {
            let cleaned;

            const open_i  = selection.indexOf(open);
            const close_i = selection.lastIndexOf(close);

            // remove existing (inner) DText before adding new one
            if (open_i !== -1 && close_i > open_i) {
              cleaned = selection.substring(0, open_i)
                + selection.substring(open_i  + open.length, close_i)
                + selection.substring(close_i + close.length);
            } else {
              cleaned = selection;
            }

            insert = open + cleaned + close;
            // select 'cleaned'
            sel_off_a = open.length;
            sel_off_b = -(open.length + close.length);
          }
        }

        const sel_a = a + sel_off_a;
        const sel_b = sel_a + insert.length + sel_off_b;

        wiki_body.value = text.substring(0, cut_a) + insert + text.substring(cut_b);
        wiki_body.setSelectionRange(Math.max(0, sel_a), sel_b);
        wiki_body.focus();
        return false;
      };

      // add accesskeys and titles
      if (code.length === 1) {
        btn.accessKey = code;
        btn.title =
          code === 'b' ? 'bold' :
          code === 'i' ? 'italic' :
          code === 's' ? 'strikethrough' :
          code === 'u' ? 'underline' : '';
      } else {
        btn.accessKey =
          code === 'code'    ? 'c' :
          code === 'quote'   ? 'q' :
          code === 'spoiler' ? 'o' :
          code === '[[...]]' ? 'w' :
          code === '{{...}}' ? 'p' :
          code === 'URL'     ? 'l' : '';

        if (code === '[[...]]') btn.title = 'accesskey: w';
        if (code === '{{...}}') btn.title = 'accesskey: p';
      }

      const u = // character to underline
        code === 'code'    ? 0 :
        code === 'quote'   ? 0 :
        code === 'spoiler' ? 2 :
        code === 'URL'     ? 2 :
        code.length === 1  ? 0 : -1;

      if (u !== -1) {
        btn.innerHTML = `${code.substring(0, u)}<span style="text-decoration: underline;">${code[u]}</span>${code.substring(u + 1)}`;
      } else {
        btn.innerText = code;
      }

      div.appendChild(btn);
    }

    wiki_form.appendChild(div);
  }

  function add_wiki_template() {
    if (config.wiki_template.length === 0) return;

    const wiki_form = document.getElementById('wiki-form');
    const wiki_body = document.getElementById('wiki_page_body');

    if (wiki_form === null || wiki_body === null) {
      show_notice(console.error, '[addon error] couldn\'t find "wiki-form" or "wiki_page_body", wiki template disabled');
      return;
    }

    wiki_form.style.display = 'flex'; // add template to the right

    const div = document.createElement('DIV');
    div.style.marginLeft = '1em';

    const template_label = document.createElement('LABEL');
    template_label.innerText = 'Wiki Template';
    template_label.style.cursor = 'help';
    template_label.style.textDecoration = 'underline dashed';
    template_label.title = 'Selected text can be appended to the page body. Clicking or using arrow keys selects a whole line, pressing \'c\' or the button below copies the selection over';

    const template_text = document.createElement('TEXTAREA');
    template_text.id = 'wiki_template_text';
    template_text.cols = wiki_body.cols;
    template_text.rows = wiki_body.rows;
    template_text.style.width = '33em';
    template_text.value = config.wiki_template;

    const insert_template_selection = () => {
      const text = template_text.value;
      const a = template_text.selectionStart;
      const b = template_text.selectionEnd;

      const selection = text.substring(a, b);
      const add_newline = wiki_body.value && !wiki_body.value.endsWith('\n');
      wiki_body.value += (add_newline ? '\n' : '') + selection;
    };

    const extend_selection = () => {
      // extend empty selection to newlines (or text start/end)
      const text = template_text.value;
      let a = template_text.selectionStart;
      let b = template_text.selectionEnd;

      if (a === b) {
        const ext_a = text.lastIndexOf('\n', a - 1);
        a = (ext_a !== -1 ? ext_a + 1 : 0);

        if (text.charAt(b) !== '\n') {
          const ext_b = text.indexOf('\n', b + 1);
          b = (ext_b !== -1 ? ext_b - 1 : text.length - 1) + 1;
        }

        template_text.setSelectionRange(a, b);
      }
    };

    template_text.readOnly = true; // hides the caret and there's no easy workaround
    template_text.addEventListener('click', extend_selection);
    template_text.addEventListener('keyup', extend_selection);
    template_text.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.altKey || e.shiftKey) return;
      if (e.key === 'c') insert_template_selection();
    });

    const btn = create_button();
    btn.innerText = 'Copy selection over';
    btn.style.fontWeight = 'bold';
    btn.style.padding = '0.2em 2em';
    btn.style.margin = '0.1em';
    btn.onclick = () => { insert_template_selection(); template_text.focus(); return false; };

    div.appendChild(template_label);
    div.appendChild(document.createElement('BR'));
    div.appendChild(template_text);
    div.appendChild(document.createElement('BR'));
    div.appendChild(btn);
    wiki_form.appendChild(div);

    div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top - template_label.getBoundingClientRect().height) + 'px';
  }

  function add_record_template() {
    const record_body = document.getElementById('user_record_body');
    const record_score = document.getElementById('user_record_score');

    if (record_body === null || record_score === null) {
      show_notice(console.error, 'couldn\'t find record elements, disabled template');
      return;
    }

    const label = document.createElement('SPAN');
    label.innerText = 'Template:';
    label.style.marginLeft = '0.5em';
    label.style.marginRight = '0.5em';
    insert_node_after(label, record_score);

    // the JSON is supposed to be an array of 2-or-3-entry-arrays, which converts to Map
    const raw_templates = JSON.parse(config.record_template);

    const templates = new Map();
    for (const [title, content, raw_score] of raw_templates) {
      let score = null;
      if (typeof raw_score !== 'undefined') {
        score = raw_score.toLowerCase();

        if (score === 'neutral') score = 0;
        else if (score === 'positive') score = 1;
        else if (score === 'negative') score = -1;
        else score = null;
      }

      if (score === null) {
        show_notice(console.error, '[addon error] record template has invalid score, see console for details', [title, content, raw_score]);
      }

      templates.set(title, { content, score });
    }

    const apply_template = (template) => {
      record_body.value = template.content;
      if (template.score !== null) record_score.value = template.score;
    };

    const dropdown = create_template_dropdown(templates, apply_template);
    dropdown.id = 'template_dropdown';
    insert_node_after(dropdown, label);
  }

  function add_tagscript_presets() {
    const mode_menu = document.getElementById('mode-menu');
    const mode_dropdown = document.getElementById('mode');

    if (mode_menu === null || mode_dropdown === null) {
      show_notice(console.error, 'couldn\'t find "Mode" elements, disabled presets');
      return;
    }

    // the JSON is supposed to be an array of 2-entry-arrays, which converts to Map
    const presets = new Map(JSON.parse(config.tagscript_presets));

    if (presets.size === 0)
      return;

    mode_menu.appendChild(document.createElement('P'));

    const label = document.createElement('H5');
    label.innerText = 'Tag Script Presets';
    mode_menu.appendChild(label);

    const set_tag_script = (script) => {
      set_cookie('tag-script', script);
      mode_dropdown.value = 'apply-tag-script';
      PostModeMenu_change_override();
    };

    const dropdown = create_template_dropdown(presets, set_tag_script);
    dropdown.id = 'tagscript_presets_dropdown';
    mode_menu.appendChild(dropdown);
  }

  function create_template_dropdown(templates, change_event) {
    const dropdown = document.createElement('SELECT');

    const empty_option = document.createElement('OPTION');
    empty_option.disabled = true;
    empty_option.selected = true;
    empty_option.style.display = 'none';
    dropdown.appendChild(empty_option);

    for (const [title, value] of templates.entries()) {
      const option = document.createElement('OPTION');
      option.innerText = title;

      // somewhat redundant to the change event but allows to "reset" the text to an already selected template
      option.addEventListener('click', (e) => {
        change_event(value);
        e.preventDefault();
      });

      dropdown.appendChild(option);
    }

    dropdown.addEventListener('change', (e) => {
      change_event(templates.get(dropdown.value));
      e.preventDefault();
    });

    return dropdown;
  }

  function add_tag_edit_gear() {
    try {
      // add a "⚙" link to the edit tag page
      const h2 = document.getElementsByClassName('title')[0];
      const tag = new URL(window.location.href).searchParams.get('title');
      const wiki_edit_link = document.createElement('A');
      wiki_edit_link.href = '/tag/edit?name=' + tag;
      wiki_edit_link.innerText = '⚙';
      wiki_edit_link.title = 'Edit Tag';
      h2.appendChild(wiki_edit_link);
    } catch (error) {
      show_notice(console.error, '[addon error] couldn\'t add "⚙" tag page link, check console', error);
    }
  }

  function add_custom_duplicate_delete_reason() { // and sanity checks
    const reason = document.getElementById('reason');
    const custom_reason = document.getElementById('custom_reason');

    const thumbs = document.querySelectorAll('#content > .deleting-post .thumb');
    if (thumbs.length !== 2)
      return; // no parent

    const [child_tags, parent_tags] = [get_thumbnail_tags(thumbs[0]), get_thumbnail_tags(thumbs[1])];
    const parent_id = thumbs[1].id.substring(1);

    const custom_dupe_option = document.createElement('OPTION');
    custom_dupe_option.innerText = `duplicate of ${parent_id} (custom reason)`;
    reason.appendChild(custom_dupe_option);

    const warn_for = (tag) => {
      if (child_tags.includes(tag)) {
        show_notice(console.log, `[addon] child post has '${tag}' tag!`);
        return false;
      }

      if (parent_tags.includes(tag)) {
        show_notice(console.log, `[addon] parent post has '${tag}' tag!`);
        return false;
      }

      return true;
    };

    reason.addEventListener('change', (e) => {
      const i = reason.selectedIndex;
      if (i === 0) return;

      const is_custom_dupe = reason.options[i] === custom_dupe_option;
      const is_dupe = (1 <= i && i <= 4) || is_custom_dupe;

      // duplicate sanity checks
      if (is_dupe) {
        const ok = warn_for('upscaled')
          && warn_for('legitimate_variation')
          && warn_for('revision');

        if (ok && !child_tags.includes('duplicate')) {
          show_notice(console.log, '[addon] child post isn\'t tagged as duplicate!');
        }
      }

      if (is_custom_dupe) {
        reason.selectedIndex = 0;
        custom_reason.value = `duplicate of ${parent_id} (...)`;
        custom_reason.focus();
        custom_reason.setSelectionRange(custom_reason.value.length - 4, custom_reason.value.length - 1);
      }
    });
  }

  function add_tags_copy_button() {
    const tags_not_present = document.querySelector('#content > .deleting-post > ul > li:nth-child(7)');
    if (tags_not_present === null) return;

    let tags_diff = ' ';
    for (const a of tags_not_present.getElementsByTagName('A')) {
      const tag = a.innerText;
      if (['duplicate', 'potential_duplicate'].includes(tag)) continue; // TODO: ignore all meta tags?
      tags_diff += tag.replaceAll(/ /g, '_') + ' ';
    }

    const button = document.createElement('BUTTON');
    button.type = 'button';
    button.innerText = 'Copy Tags';
    button.onclick = () => {
      set_clipboard(tags_diff);
    };

    tags_not_present.appendChild(button);
  }


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

  await load_config();

  // listen for config changes in other windows
  if (USE_MONKEY_STORAGE) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed);
  else if (USE_LOCAL_STORAGE) window.addEventListener('storage', local_storage_changed);
  else show_notice(console.error, '[addon error] couldn\'t add storage change listener! No cross-tab communication possible.');

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

  // skip language codes in pathnames like "/jp/post/show"
  let pathname = window.location.pathname;
  if (pathname.indexOf('/', 1) === 3)
    pathname = pathname.substring(3);

  if (pathname === '/' || pathname === '/post/index' || 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
    const observer = register_observer((node) => {
      return (node.value === 'apply-tag-script');
    }, (node, observer) => {
      observer.disconnect();

      // don't pass node.parentNode, because it can be undefined or the <div> above for reasons I cannot understand
      add_mode_options();
      add_tagscript_presets();
      return true;
    });

    const dropdown = document.getElementById('mode');
    if (dropdown !== null) {
      for (const child of dropdown.children) {
        if (child.value === 'apply-tag-script') { // it's already there
          observer.disconnect(); // stop looking for it
          add_mode_options(dropdown);
          add_tagscript_presets();
          break;
        }
      }
    }

    // add thumbnail icons and fade out thumbnails for dynamically loaded posts (from auto paging)
    add_thumbnail_observer((node) => (node.classList != null && node.classList.contains('content-page')));


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

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

    // mute/pause videos
    const observer = register_observer((node) => {
      return (node.id === 'image');
    }, (node, observer) => {
      configure_video(node);
      return true;
    });

    const video = document.getElementById('image');
    if (video !== null) {
      observer.disconnect();
      configure_video(video);
    }

    add_thumbnail_observer((node) => (node.id === 'recommendations'));

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

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

    add_thumbnail_observer((node) => (node.id === 'recommendations'));

  }




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

  async function init() {
    // sitefix for flagged posts not always showing red border
    // issue: "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
    const css = '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;}'
      + ' a.tag_nonexistent { color: #E00; }'; // custom style for tag menu
    add_style(css);

    add_config_dialog();
    if (IS_MONKEY) GM.registerMenuCommand('Open Addon Config', () => show_config_dialog(true), 'C');
    add_config_button();
    update_config_dialog();

    update_headerlogo();

    // process what the thumbnail observer may have missed
    modify_thumbnails(document);

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

    if (pathname === '/' || pathname === '/post/index' || pathname.startsWith('/post/similar')) {

      if (config.tag_search_buttons) add_tag_search_buttons();
      add_postmode_hotkeys();
      add_post_edit_dialog();

      if (called_add_mode_options) {
        // add_mode_options() was called early, as it should
        PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page
      } else {
        // if not, catch up on it later
        call_postmodemenu_init_workaround = true;
      }

      if (!IS_GREASEMONKEY4) {
        // try to guarantee sitescript has loaded TODO: future note: potential infinite loop
        while (unsafeWindow.PostModeMenu === undefined)
          await sleep(100);

        PostModeMenu_click_original = unsafeWindow.PostModeMenu.click;
        // TODO will not work on Greasemonkey (need to replace click events just like with the mode change event)
        unsafeWindow.PostModeMenu.click = PostModeMenu_click_override;
      }


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

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

      const hidden_post_id_el = document.getElementById('hidden_post_id');
      if (hidden_post_id_el !== null) {
        post_id = hidden_post_id_el.innerText;
      } else {
        const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
        post_id = temp.substring(temp.lastIndexOf('/') + 1);
      }

      post_parent_id = document.getElementById('post_parent_id');
      if (post_parent_id !== null)
        parent_id = post_parent_id.value;

      if (config.view_history_enabled) {
        config[HISTORY_KEY].add(Number(post_id));
        save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history
      }

      if (config.tag_search_buttons) add_tag_search_buttons();
      if (config.tag_category_collapser) add_tag_category_collapser();

      if (config.add_filetype_stat) add_filetype_stat();
      fix_pixiv_source_link();

      if (config.move_stats_to_edit_form) move_stats_to_edit_form();
      add_tag_buttons();
      if (config.tag_menu) add_tag_menu();
      update_tag_elements(); // initialize tag menu/buttons
      add_tags_change_listener();
      add_tags_submit_listener();

      add_postpage_hotkeys();
      if (config.scale_on_resize) add_scale_on_resize_listener();

      read_image_data().then(() => {
        if (config.scale_image) scale_image(config.scale_mode, false);
        else note_fix();
        if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center);
        add_resize_notice_listener();
        add_highres_listener();
        load_highres();
      });

      find_actions_list().then((actions_ul) =>
        add_addon_actions(actions_ul));


    /*************/
    /* wiki page */
    /*************/

    } else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit')) {

      add_dtext_style_buttons();
      add_wiki_template();

    } else if (pathname.startsWith('/wiki/show')) {

      add_tag_edit_gear();

    } else if (pathname.startsWith('/user_record/create')) {

      add_record_template();


    /***************/
    /* delete page */
    /***************/

    } else if (pathname.startsWith('/post/delete')) {

      document.getElementById('custom_reason').style.width = '25%';
      add_custom_duplicate_delete_reason();
      add_tags_copy_button();

    }

  }

  if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
    init().catch((reason) => {
      show_notice(console.error, '[addon error] init() failed, check console', reason);
    });
  } else {
    document.addEventListener('DOMContentLoaded', init, false);
  }
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);