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.

Per 20-10-2023. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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.94
// @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/*
// @match       https://legacy.sankakucomplex.com/*
// @run-at      document-start
// @noframes
// @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.94';

  const SVG_SIZE = 20;
  const SPEAKER_SVG = `<svg class="speaker_icon" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
 <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
 <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  <rect width="32" height="2" stroke-width=".074927"/>
  <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  <rect y="2" width="2" height="28" stroke-width=".075839"/>
  <rect y="30" width="32" height="2" stroke-width=".07698"/>
 </g>
 <path d="m19 11c6 5 0 10 0 10" fill="none" stroke="#1cd9ff" stroke-width="2"/>
 <path d="m23 9c8 7 0 14 0 14" fill="none" stroke="#1cd9ff" stroke-width="2"/>
 <path d="m16 23h-3l-3-3h-5v-8h5l3-3h3" fill="#ff761c"/>
</svg>`;

  const ANIMATED_SVG = `<svg class="animated_icon" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
 <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
 <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  <rect width="32" height="2" stroke-width=".074927"/>
  <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  <rect y="2" width="2" height="28" stroke-width=".075839"/>
  <rect y="30" width="32" height="2" stroke-width=".07698"/>
 </g>
 <path d="m18 16-10 6v-12" fill="#ff761c"/>
 <path d="m26 16-9 6v-12" fill="#ff761c"/>
</svg>`;

  const EXPLICIT_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
 <g>
  <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
   <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
   <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
   <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
   <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  </g>
  <g transform="scale(.9953 1.0047)" fill="#f99" aria-label="S">
   <rect x="12.057" y="5.9718" width="11.052" height="2.9859" stroke-width="1.0856"/>
   <rect x="12.057" y="22.892" width="11.052" height="2.9859" stroke-width="1.0856"/>
   <rect x="12.057" y="14.432" width="9.0425" height="2.9859" stroke-width=".98198"/>
   <rect x="9.0425" y="5.9718" width="3.0142" height="19.906" stroke-width="1.0541"/>
  </g>
 </g>
</svg>`;

  const QUESTIONABLE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
 <g>
  <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
   <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
   <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
   <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
   <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  </g>
  <g transform="translate(6.9551 5.0704)" fill="#999" aria-label="S">
   <path d="m11.545 14.93 5 4-2 2-5-4" fill="#999"/>
  </g>
  <text x="-13" y="4" font-family="'Andika New Basic'" font-size="40px" style="line-height:1.25" xml:space="preserve"><tspan x="-13" y="4"/></text>
  <path d="m16 6.5a7 9.5 0 0 0-7 9.5 7 9.5 0 0 0 7 9.5 7 9.5 0 0 0 7-9.5 7 9.5 0 0 0-7-9.5zm0 1.5a5 8 0 0 1 5 8 5 8 0 0 1-5 8 5 8 0 0 1-5-8 5 8 0 0 1 5-8z" fill="#999"/>
 </g>
</svg>`;

  const SAFE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
 <g>
  <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
   <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
   <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
   <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
   <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  </g>
  <g transform="scale(.9953 1.0047)" fill="#9f9" aria-label="S">
   <path d="m21.586 9.7289q-0.78306-0.39548-1.4501-0.65914-0.65255-0.27684-1.2761-0.44822-0.62355-0.18456-1.2471-0.26366-0.62355-0.079096-1.3196-0.079096-0.87006 0-1.5951 0.23729-0.71055 0.23729-1.2326 0.63277-0.52204 0.39548-0.81206 0.90961-0.27552 0.51413-0.27552 1.0546 0 0.5405 0.15951 0.98871 0.17401 0.43503 0.68155 0.83052 0.50754 0.3823 1.4501 0.7646 0.94257 0.36912 2.4942 0.77779 1.4356 0.3823 2.5377 0.89643 1.1166 0.50095 1.8706 1.1601t1.1456 1.5028q0.39153 0.8437 0.39153 1.9115 0 1.3315-0.58004 2.4256-0.56554 1.081-1.5661 1.872-0.98607 0.77779-2.3057 1.2128-1.3196 0.42185-2.7987 0.42185-1.0441 0-2.0447-0.13183-0.98607-0.13183-1.8706-0.36912-0.87006-0.23729-1.6096-0.55368-0.73955-0.32957-1.2906-0.72506l0.65255-2.7025q1.4501 1.1469 2.9872 1.661 1.5516 0.51413 3.1757 0.51413 0.87006 0 1.6821-0.22411t1.4356-0.64596q0.62355-0.43503 1.0006-1.0546 0.37703-0.63278 0.37703-1.4369 0-0.51413-0.18851-0.97553-0.17401-0.47458-0.72505-0.9228-0.55104-0.44822-1.5806-0.88325-1.0296-0.44822-2.7262-0.90961-1.6966-0.4614-2.7697-1.0151-1.0731-0.55368-1.6821-1.1996-0.60904-0.65914-0.84106-1.3974-0.21752-0.75142-0.21752-1.5951 0-0.8437 0.37703-1.7797 0.39153-0.93598 1.2181-1.7269 0.82656-0.79097 2.1172-1.3051 1.2906-0.52731 3.1032-0.52731 0.84106 0 1.5661 0.065914 0.73956 0.065914 1.4211 0.21092 0.69605 0.14501 1.3631 0.36912 0.66705 0.22411 1.3776 0.5405z" fill="#9f9"/>
  </g>
 </g>
</svg>`;

  const RATING_SVG = {
    'r18+': EXPLICIT_SVG,
    'r15+': QUESTIONABLE_SVG,
    'g': SAFE_SVG,
  };

  // 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_hair 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"
    ]
  }
]`;

  const POST_MODE_DESCRIPTIONS = {
    'view': 'View',
    'add-fav': 'Add to favorites',
    'remove-fav': 'Remove from favorites',
    'rating-s': 'Rate G',
    'rating-q': 'Rate 15+',
    'rating-e': 'Rate R18+',
    'approve': 'Approve post',
    'flag': 'Flag',
    'edit-tag-script': 'Edit tag script',
    'apply-tag-script': 'Apply tag script',
    'choose-parent': 'Choose Parent',
    'set-parent': 'Set Parent',
    'edit-tags': 'Edit Tags',
    'find-similar': 'Find Similar',
    'delete': 'Delete Post',
  };

  const TAG_CATEGORIES = [
    'copyright',
    'studio',
    'character',
    'artist',
    'medium',
    'meta',
    'genre'
  ];

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

  let IS_MONKEY = false; // Tampermonkey, Violentmonkey, Greasemonkey (all at least partially support 'GM.' functions)

  if (typeof GM === 'object' && typeof GM.info === 'object') {
    IS_MONKEY = true;

    // Greasemonkey:
    // doesn't have addStyle and addValueChangeListener
    // fetch() doesn't work with relative URLs (https://github.com/greasemonkey/greasemonkey/issues/2647), workaround: new URL('/relative/path', document.location)

    // polyfill for ViolentMonkey
    if (!GM.addValueChangeListener && typeof GM_addValueChangeListener !== 'undefined') GM.addValueChangeListener = GM_addValueChangeListener;
  }

  const HAS_MONKEY_STORAGE = IS_MONKEY;
  const HAS_MONKEY_STORAGE_LISTENER = IS_MONKEY && GM.addValueChangeListener;
  const HAS_MONKEY_ADD_STYLE = IS_MONKEY && GM.addStyle;

  let HAS_LOCAL_STORAGE;
  try {
    HAS_LOCAL_STORAGE = !!localStorage.getItem;
  } catch (error) { // DOMException
    HAS_LOCAL_STORAGE = false;
  }

  function add_storage_change_listener() {
    if (HAS_MONKEY_STORAGE_LISTENER) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed);
    else if (HAS_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.');
  }

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

  function add_style(css) {
    if (HAS_MONKEY_ADD_STYLE) {
      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: {
            key: value.key,
            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' && value !== null) {
      switch (value.t) {
        case 'Set':
          return new Set(value.v);
        case 'Map':
          return new Map(value.v);
        case 'Hotkey':
          return new Hotkey(value.v.action, value.v.key, value.v.modifiers);
      }
    }

    return value;
  }

  function adjust_tag_color_css() {
    for (const category of TAG_CATEGORIES) {
      if (!config.tag_category_colors.has(category)) continue;

      add_style(`
        .tag_button.tag-type-${category}:not(.tag_nonexistent) {
          color: ${config.tag_category_colors.get(category)};
        }
      `);
    }
  }

  let applied_css = false;
  function adjust_css() {
    if (applied_css) return;
    applied_css = true;

    // change priority of post borders (by redefining their colors after their original definition)
    // original: flagged < has-children < has-parent < pending < deleted
    // default:  deleted < has-children < has-parent < pending < flagged
    // variants: pending < deleted < has-children < has-parent < flagged
    // note: pending, flagged and deleted are mutually exclusive, so this is only about their relation to has-children/parent
    // also note: deleted posts only show through explicit search so they don't really need a border
    add_style(`
      img.has-children { border-color: #A7DF38; }
      img.has-parent   { border-color: #CCCC00; }
    ${config.post_border_style === 0 ? `
      img.pending      { border-color: #4B4BA3; }
    ` : config.post_border_style === 2 ? `
      img.pending:is(.has-children,.has-parent) { outline: #4B4BA3 solid 2px; }
    ` : ''}
      img.flagged      { border-color: #F00; }
    `);

    /* sitefix for thumbnails not aligning properly anymore */
    add_style('span.thumb { float: left; }');

    /* shrink size of tag edit buttons */
    add_style('tr input[type="submit"], tr button { min-width: unset; }');

    /* readd missing thumbnail padding (so thumbnail icons don't jump around) */
    add_style(`
      span.thumb .preview:not(:hover,.has-parent,.has-children,.pending,.flagged,.deleted) {
          padding: 2px;
      }
    `);

    /* sitefix: there can appear a small gap between the navbar <li>s and the <ul>s which partially breaks hovering */
    /* this issue seems to be font-dependent and is worsened by the unicode config gear icon, no matter it's size (...?!) */
    add_style(`
      div#header ul#navbar {
        margin-bottom: 0;
        padding-top: 2px;
        padding-bottom: 0;
      }
      div#header ul#navbar li {
        padding-bottom: 3px;
      }
      div#header ul#navbar li:hover > ul {
        height: 2.1em;
      }
    `);

    /* sitefix for broken deletion page layout */
    add_style(`
      /* comparison box */
      #content > .deleting-post {
          clear: left;    /* clearfix: ensure box starts below first thumbnail */
          overflow: auto; /* fit its content */
      }

      /* balance margins */
      #content > .deleting-post {
          padding-top: 1em;
          padding-bottom: 1em;
      }
      #content > .deleting-post > div {
          margin-top: unset !important; /* important due to inline style */
          margin-bottom: unset !important;
      }
      #content > .deleting-post > ul {
          margin-bottom: unset;
      }

      /* align first thumbnail with comparison box */
      #content > .thumb {
          margin-left: calc(4em + 4px);
      }

      /* center warn tags / edit gear below thumbnails */
      #content .deleting-post .thumb > * {
          margin: auto;
      }
    `);

    /* replace thumbnail centering logic (more robust, needed for thumbnail icons) */
    add_style(`
      span.thumb {
          display: grid;
          place-content: center;
      }

      img.preview {
          position: static;
      }

      /* needed for thumbnail icons */
      .thumb a {
        position: relative;
      }
    `);

    /* custom style for tag menu */
    add_style(`
      .tag_button {
        display: inline-block;
        border-style: solid;
        border-width: 1px;
        padding-left: 5px;
        padding-right: 5px;
      }

      .tag_list, .tag_group {
        display: flex;
        flex-wrap: wrap;
        align-content: flex-start;
        align-items: flex-start;
        column-gap: 6px;
        row-gap: 3px;
      }

      .tag_list {
        padding-left: 3px;
        padding-top: 3px;
        padding-bottom: 1px;
      }

      .tag_group {
        padding: 2px;
      }

      .tag_list table {
        margin: 0;
      }

      .tag_group, .tag_list table {
        margin-left: -3px;
        margin-top: -2px;
      }

      a.tag_nonexistent {
        color: #E00;
      }
    `);

    adjust_tag_color_css();

    // disable site icons when using own icons
    if (config.show_speaker_icon || config.show_animated_icon) {
      add_style(`
        .sound-icon, .video-icon {
          display: none;
        }
      `);
    }
  }


  /***************************/
  /* 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 HOTKEY_ACTIONS = { // actions need unique names (for serialization)
    postpage: {
      reset_size: () => {
        scale_image(SCALE_MODES.RESET, true);
        scroll_to_image();
      },
      fit_size: () => {
        scale_image(SCALE_MODES.FIT, true);
        scroll_to_image();
      },
      fit_horizontal: () => {
        scale_image(SCALE_MODES.HORIZONTAL, true);
        scroll_to_image();
      },
      fit_vertical: () => {
        scale_image(SCALE_MODES.VERTICAL, true);
        scroll_to_image();
      },
      open_similar: () => {
        if (PostPage.post_id === null) {
          show_notice(console.error, 'addon error: no post id, report to author!');
          return;
        }
        open_in_tab(window.location.origin + '/post/similar?id=' + PostPage.post_id);
      },
      open_delete: () => {
        if (!found_delete_action) {
          show_notice(console.error, 'addon error: Delete action not found, no permission?');
        } else {
          if (PostPage.post_id === null) {
            show_notice(console.error, 'addon error: no post id, report to author!');
            return;
          }
          open_in_tab(window.location.origin + '/post/delete/' + PostPage.post_id);
        }
      },
      add_translation: () => {
        unsafeWindow.Note.create();
      },
      copy_translations: () => {
        copy_translations();
      },
      paste_translations: () => {
        paste_translations();
      },
    },
    indexpage: {}
  };

  for (const mode of Object.keys(POST_MODE_DESCRIPTIONS)) {
    HOTKEY_ACTIONS.indexpage[`${mode}_mode`] = () => select_mode(mode);
  }

  for (let i = 1; i <= 10; i++) {
    HOTKEY_ACTIONS.indexpage[`select_tagscript_preset${i}`] = () => {
      const dropdown = document.getElementById('tagscript_presets_dropdown');
      dropdown.selectedIndex = i;
      dropdown.dispatchEvent(new Event('change'));
    };
  }

  function get_hotkey_action(action_name) {
    for (const actions of Object.values(HOTKEY_ACTIONS)) {
      const action = actions[action_name];
      if (action) {
        return action;
      }
    }

    throw new Error(`Hotkey ${action_name} not found`);
  }

  class Hotkey {
    constructor(action_name, key, modifiers) {
      this.key = key;
      this.modifiers = modifiers ?? new Set();
      this.action_name = action_name;
      this.action = get_hotkey_action(action_name);
    }

    call(e) {
      if (e.key.toLowerCase() === this.key
        && 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,
    redirect_v_to_s_server: false,
    show_speaker_icon: false,
    show_animated_icon: false,
    show_ratings_icon: false,
    post_border_style: 0,
    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"]} ]',
    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(),
    move_stats_to_edit_form: false,
    postpage_hotkeys: [
      new Hotkey('reset_size', 'h'),
      new Hotkey('fit_size', 'g'),
      new Hotkey('fit_horizontal', 'g', new Set(['shift'])),
      new Hotkey('fit_vertical', 'g', new Set(['alt'])),
      //new Hotkey('open_similar', 's'),
      //new Hotkey('open_delete', 'd'), // site: shift+d
      //new Hotkey('add_translation', 't'), // site: n
      new Hotkey('copy_translations', 'c'),
      new Hotkey('paste_translations', 'v'),
    ],
    indexpage_hotkeys: [
      new Hotkey('set-parent_mode', 'v'),
      new Hotkey('choose-parent_mode', 'c'),
      new Hotkey('rating-s_mode', 's'),
      new Hotkey('rating-q_mode', 'q'),
      new Hotkey('rating-e_mode', 'e'),
    ],
    notes: [],
    save_tag_categories: true,
    tag_categories: new Map(),
    tag_category_colors: new Map(),
    use_old_wiki: false,
    use_old_pools: false,
    use_old_tags_index: false,
  };

  for (let i = 1; i <= 10; i++) {
    const key = i < 10 ? String(i) : '0';
    DEFAULT_CONFIG.indexpage_hotkeys.push(new Hotkey(`select_tagscript_preset${i}`, key));
  }

  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 (to e.g. fix config elements returning strings when we need numbers)
  const CONFIG_FIXER = {
    scale_mode: Number,
    tag_menu_layout: Number,
    tag_category_collapser_style: Number,
    highres_limit: Number,
    post_border_style: Number,
  };

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

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

    if (HAS_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);
      });

      // use localStorage too if we don't have a change listener
      if (GM.addValueChangeListener) return;
    }

    if (!HAS_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 (HAS_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 && HAS_LOCAL_STORAGE)
        stored_value = localStorage.getItem(KEY_PREFIX + key);

      if (stored_value !== undefined && stored_value !== null) {
        try {
          value = JSON.parse(stored_value, json_reviver);

          // "migrate" from old hotkey format by resetting to default
          if ((key === 'postpage_hotkeys' || key === 'indexpage_hotkeys') && !Array.isArray(value)) {
            reset_setting(key);
            continue;
          }
        } 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 = General.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 (HAS_MONKEY_STORAGE) GM.deleteValue(key);
    if (HAS_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', 'indexpage_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',
        'redirect_v_to_s_server',
      ],
    },
    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',
        'post_border_style',
        'hide_headerlogo',
      ],
    },
    editing: {
      name: 'Editing',
      entries: [
        'use_old_wiki',
        'use_old_pools',
        'use_old_tags_index',
        'move_stats_to_edit_form',
        'setparent_deletepotentialduplicate',
        'editform_deleteuselesstags',
        'tag_menu',
        COMMON_TAGS_KEY,
        'tag_menu_layout',
        'save_tag_categories',
        'wiki_template',
        'record_template',
        'tagscript_presets',
      ],
    },
    postpage_hotkeys: {
      name: 'Post Page Hotkeys',
      entries: [],
    },
    indexpage_hotkeys: {
      name: 'Index Page Hotkeys',
      entries: [],
    },
  };

  // expand hotkeys
  for (const [page, actions] of Object.entries(HOTKEY_ACTIONS)) {
    for (const name of Object.keys(actions)) {
      CONFIG_CATEGORY_TEMPLATE[`${page}_hotkeys`].entries.push(`${page}_hotkeys.${name}`);
    }
  }

  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*'},
    redirect_v_to_s_server:             {type: 'checkbox', desc: 'Redirect v.sankakucomplex.com to s.sankakucomplex.com'},
    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 ${SPEAKER_SVG} icon on thumbnail if it has audio*`},
    show_animated_icon:                 {type: 'checkbox', desc: `Show ${ANIMATED_SVG} icon on thumbnail if it is animated (${SPEAKER_SVG}  overrides ${ANIMATED_SVG} )*`},
    show_ratings_icon:                  {type: 'checkbox', desc: `Show ratings icon (${SAFE_SVG}, ${QUESTIONABLE_SVG}, ${EXPLICIT_SVG}) on post thumbnails*`},
    view_history_enabled:               {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'},
    post_border_style:                  {type: 'select',   desc: 'Post border style: ', options: {0: 'Prioritize blue \'unapproved\' border', 1: 'Priorizite yellow/green \'has parent / children\' borders', 2: 'Show both borders'}},
    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'},
    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'},
    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'}},
    save_tag_categories:                {type: 'checkbox', desc: 'Save tag color information for use in tag menu'},
    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).'},
    move_stats_to_edit_form:            {type: 'checkbox', desc: 'Move post "Details" to the right of the edit form*'},
    use_old_wiki:                       {type: 'checkbox', desc: 'Use old wiki where possible'},
    use_old_pools:                      {type: 'checkbox', desc: 'Use old pools instead of books (read-only)'},
    use_old_tags_index:                 {type: 'checkbox', desc: 'Use old tags index'},
    // TODO this is a mess, hotkeys should have a description
    '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'},
    'postpage_hotkeys.copy_translations':  {type: 'hotkey',   desc: 'Copy Translations'},
    'postpage_hotkeys.paste_translations': {type: 'hotkey',   desc: 'Paste Translations'},
  };

  for (const [mode, desc] of Object.entries(POST_MODE_DESCRIPTIONS)) {
    SETTINGS_TEMPLATE[`indexpage_hotkeys.${mode}_mode`] = {type: 'hotkey', desc: desc + ' mode'};
  }

  for (let i = 1; i <= 10; i++) {
    SETTINGS_TEMPLATE[`indexpage_hotkeys.select_tagscript_preset${i}`] = {type: 'hotkey', desc: `Select Tag Script Template #${i}`};
  }

  // 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.endsWith('_hotkeys')) {
      update_hotkeys(key.slice(0, -'_hotkeys'.length));
      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==';

  class Tags { // thin wrapper around Set
    tags;

    constructor(tag_str) {
      tag_str ??= '';
      this.tags = new Set(tag_str.trim().split(/\s+/).filter(t => t.length !== 0));
    }

    static invert(tag) {
      const minus = tag.startsWith('-');
      return minus ? tag.substring(1) : `-${tag}`;
    }

    has(tag) {
      return this.tags.has(tag);
    }

    add(tag) {
      this.tags.add(tag);
      this.tags.delete(Tags.invert(tag));
    }

    remove(tag) {
      this.tags.delete(tag);
    }

    toggle(tag) {
      if (this.has(tag)) {
        this.remove(tag);
      } else {
        this.add(tag);
      }
    }

    filter(pred) {
      this.tags = new Set([...this.tags].filter(pred));
      return this;
    }

    [Symbol.iterator]() {
      return this.tags.values();
    }

    toArray() {
      return [...this.tags];
    }

    toString() {
      return [...this.tags].join(' ');
    }
  }

  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=/; SameSite=Lax`;
  }

  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 modify_nodes(node_selector, node_modifier, root_selector) {
    // MutationObserver (with childList: true, subtree: true) will observe every single node from the HTML as "added",
    // but for script-inserted node trees, only the root node is counted.
    // As a workaround, all subnodes of nodes matching root_selector will be checked manually

    const observer = new MutationObserver(mutations => {
      // for each added element
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;

          // check for match
          if (node.matches(node_selector) && node_modifier(node, observer)) { // are we done?
            observer.disconnect();
            return;
          }

          // check all subnodes of nodes matching root_selector
          if (root_selector && node.matches(root_selector)) {
            for (const subnode of node.getElementsByTagName('*')) {
              if (subnode.matches(node_selector) && node_modifier(subnode, observer)) {
                observer.disconnect();
                return;
              }
            }
          }
        }
      }
    });

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

    // it's possible we are too late to observe the element's construction, so look for it afterwards immediately
    for (const node of document.querySelectorAll(node_selector)) {
      if (node_modifier(node, observer)) {
        observer.disconnect();
        break;
      }
    }
  }

  // call adjust_css() as early as possible
  function modify_css() {
    const try_adjust_css = () => {
      if (document.body !== null) { // wait for body to guarantee head was loaded
        observer.disconnect();
        adjust_css();
      }
    };

    const observer = new MutationObserver(try_adjust_css);
    observer.observe(document, { childList: true, subtree: true });
    try_adjust_css();
  }

  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(obj.action_name, obj.key, new Set(obj.modifiers));

      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) {
    unsafeWindow.notice?.(msg[0]);
    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.removeProperty('background-color');
    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 */
  /**************************/

  class Page {
    static Index    = Symbol('index/similar');
    static Post     = Symbol('post');
    static Wiki     = Symbol('wiki add/edit');
    static WikiShow = Symbol('wiki show');
    static Tag      = Symbol('tag edit');
    static TagIndex = Symbol('tag index');
    static Moderate = Symbol('moderate');
    static Delete   = Symbol('delete');
    static User     = Symbol('user');
    static Record   = Symbol('user record');
  }

  class General {
    static page;
    static thumbnail_cache = new Map(); // id -> array of thumbnail elements
  }


  function get_search_url(tags) {
    const url = new URL(window.location.origin);
    const params = new URLSearchParams();
    params.append('tags', tags);
    url.search = params.toString();
    return url.href;
  }

  function get_search_tags(location) {
    location ??= window.location;
    return new Tags(new URL(location.href).searchParams.get('tags'));
  }

  function get_username() { // won't work on every page
    // read from the 'My Favorites' button (in one of the subnavs) in the navbar
    for (const a of document.querySelectorAll('#navbar a')) {
      if (typeof a.href !== 'string') continue;

      for (const tag of new Tags(new URL(a.href).searchParams.get('tags'))) {
        if (tag.startsWith('fav:')) {
          return tag.substring('fav:'.length);
        }
      }
    }

    return null;
  }

  // is own uploads or favorites page
  let personal_cache = null;
  function is_personal_post_page() {
    if (personal_cache !== null) return personal_cache;

    const username = get_username();
    if (username === null) {
      personal_cache = false;
      return false;
    }

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

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

  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];

        if (value === undefined) {
          console.error(`couldn't find SETTINGS_TEMPLATE[${key}]`);
          continue;
        }

        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; margin-right: 6px">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;

    // adjust inline SVG icons
    for (const svg of cfg_dialog.querySelectorAll('svg')) {
      svg.style.width = '1rem';
      svg.style.verticalAlign = 'middle';
    }

    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 = () => {
      if (window.confirm('Are you sure?\nThis will reset ALL settings, including templates and the tag menu tags!')) {
        reset_config();
        show_notice(console.log, '[addon] reset all settings');
      }
      return false;
    };
    document.getElementById('history_clear').onclick = () => {
      if (window.confirm('Are you sure you want to clear the post view history?')) {
        reset_setting(HISTORY_KEY);
        show_notice(console.log, '[addon] reset post view history');
      }
      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 [page, actions] of Object.entries(HOTKEY_ACTIONS)) {
      for (const name of Object.keys(actions)) {
        for (const suffix of ['ctrl', 'alt', 'shift', 'key']) {
          const cfg_elem = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${suffix}`);
          cfg_elem.addEventListener('change', () => {
            const hotkeys = get_hotkeys(page);
            update_setting(`${page}_hotkeys`, hotkeys);
            save_setting(`${page}_hotkeys`, hotkeys);
          });
        }
      }
    }
  }

  function get_hotkeys(page) { // get from config dialog
    const hotkeys = [];

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

      const key = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_key`).value;
      if (key.length !== 0) {
        hotkeys.push(new Hotkey(name, key, modifiers));
      }
    }

    return hotkeys;
  }

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

    // update hotkey config elements
    for (const hotkey of config[`${page}_hotkeys`]) {
      const key_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${hotkey.action_name}_key`);
      if (key_el !== null)
        key_el.value = hotkey.key;

      for (const mod of ['ctrl', 'alt', 'shift']) {
        const mod_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${hotkey.action_name}_${mod}`);
        if (mod_el !== null)
          mod_el.checked = hotkey.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
      lang_select.style.paddingRight = '35px'; // consistency between dark/light mode
    }

    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';

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

  function add_tag_search_buttons() {
    for (const item of document.querySelectorAll('#tag-sidebar li')) {
      const taglink = item.querySelector('a');
      if (taglink === null) continue;

      const tagname = get_search_tags(taglink).toString();

      const get_click_listener = (tag) => {
        return () => {
          const search_field = document.getElementById('tags');
          const search_tags = new Tags(search_field.value);

          search_tags.toggle(tag);

          search_field.value = search_tags.toString() + ' ';

          search_field.setSelectionRange(search_field.value.length, search_field.value.length);
          search_field.focus({ preventScroll: true });

          return false;
        };
      };

      const add_search_button = (tag_prefix) => {
        const a = document.createElement('A');
        a.href = '#';
        a.innerText = tag_prefix;
        a.onclick = get_click_listener((tag_prefix === '+' ? '' : tag_prefix) + tagname);

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

      add_search_button('+');
      add_search_button('-');
      if (config.or_tag_search_button)
        add_search_button('~');
    }
  }

  // needs to run before add_tag_search_buttons
  function collect_tag_categories() {
    for (const category of TAG_CATEGORIES) {
      for (const li of document.querySelectorAll(`.tag-type-${category}`)) {
        const taglink = li.querySelector('a');
        if (taglink === null) continue;

        const tag = get_search_tags(taglink).toString();
        config.tag_categories.set(tag, category);
        config.tag_category_colors.set(category, window.getComputedStyle(taglink, null).getPropertyValue('color'));
      }
    }

    adjust_tag_color_css(); // add another copy to reflect newly learned colors

    if (config.save_tag_categories) {
      // TODO this has the same storage race condition issue as the view history and the solution is way too complex and inefficient to implement here...
      save_setting('tag_categories', config.tag_categories);
      save_setting('tag_category_colors', config.tag_category_colors);
    }
  }

  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.removeProperty('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) {
    const pid = thumb.id;
    if (pid === '') return null; // first thumbnail on similar page doesn't have post id
    if (!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;
  }

  const post_cache = new Map(); // returns object with tags array and rating
  function get_post(post_id) {
    // might not be loaded yet or exist at all (e.g on deletion page)
    const internalPost = unsafeWindow.Post?.posts[post_id];
    if (internalPost !== undefined) {
      const post = { ...internalPost };

      const fix_rating = (rating) => {
        const rating_translation = { 's': 'g', 'q': 'r15+', 'e': 'r18+' };
        if (rating_translation.hasOwnProperty(rating)) return rating_translation[rating];
        return rating;
      };

      post.rating = fix_rating(post.rating);

      return post;
    }

    const post = post_cache.get(post_id);
    if (post !== undefined) return post;

    return null;
  }

  function get_post_from_thumb(thumb) {
    const img = thumb.querySelector('.preview');
    if (img === null) {
      show_notice(console.error, '[addon error] thumbnail has no preview image');
      return null;
    }

    const post_id = get_thumbnail_post_id(thumb);
    let post = get_post(post_id);

    if (post !== null) return post;

    let tags = new Tags(img.title).toArray();

    // find rating
    let rating = null;
    for (const tag of tags) {
      if (tag.startsWith('Rating:')) {
        rating = tag.substring('Rating:'.length).toLowerCase();
        break;
      }
    }

    // remove "match tags"
    tags = tags.filter((tag) => {
      return !(tag.startsWith('Rating:') || tag.startsWith('Score:') || tag.startsWith('Size:') || tag.startsWith('User:'));
    });

    post = {
      tags,
      rating,
    };

    post_cache.set(post_id, post);

    return post;
  }

  function modify_thumbnail(preview) {
    // MutationObserver is waiting for .preview so we can guarantee all the necessary data is there
    const thumb_a = preview.parentElement;
    const thumb = thumb_a?.parentElement;

    // still make extra sure stuff's in place
    if (thumb_a?.tagName !== 'A') {
      show_notice(console.error, '[addon error] thumbnail has no anchor tag?!');
      return;
    } else if (!thumb?.matches('span.thumb')) {
      show_notice(console.error, '[addon error] thumbnail has no thumb span?!');
      return;
    }

    const post_id = get_thumbnail_post_id(thumb);
    if (post_id === null) return;

    // use and update thumbnail_cache
    const thumbs = General.thumbnail_cache.get(post_id) ?? [];

    const is_new = !thumbs.includes(thumb);

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

    if (is_new) {
      override_thumbnail_click_event(thumb_a);
      add_thumbnail_icons(thumb);

      if (!is_personal_post_page() && General.page !== Page.Delete)
        fadeout_viewed_post(thumb, post_id);
    }
  }

  function add_thumbnail_icons(thumb) {
    if (!(config.show_speaker_icon || config.show_animated_icon || config.show_ratings_icon)) return;

    const post = get_post_from_thumb(thumb);
    if (post == null) return;

    const icons = document.createElement('SPAN');
    icons.style.whiteSpace = 'nowrap';

    if (config.show_ratings_icon) {
      icons.insertAdjacentHTML('beforeend', RATING_SVG[post.rating]);
    }

    if (config.show_speaker_icon && (post.tags.includes('has_audio'))) {
      icons.insertAdjacentHTML('beforeend', SPEAKER_SVG);
    } else if (config.show_animated_icon && (post.tags.includes('animated') || post.tags.includes('video') || post.tags.includes('slideshow'))) {
      icons.insertAdjacentHTML('beforeend', ANIMATED_SVG);
    }

    icons.className = 'thumbnail_icons';
    icons.style.position = 'absolute';
    icons.style.top   = '2px'; // account for border
    icons.style.right = '2px';
    icons.style.transform = `translateX(${SVG_SIZE / 2}px) translateY(-${SVG_SIZE / 2}px)`;

    thumb.querySelector('a').appendChild(icons);
  }

  function fadeout_post(thumb) {
    if (!config.view_history_enabled) return;

    const a = thumb.querySelector('a');
    const img = thumb.querySelector('img');

    // move box shadow from image to link, so opacity doesn't affect it
    a.style.display = 'inline-block';
    a.style.boxShadow = window.getComputedStyle(img).getPropertyValue('box-shadow');
    img.style.removeProperty('box-shadow');

    img.style.opacity = '20%';
    for (const thumbnail_icons of thumb.getElementsByClassName('thumbnail_icons'))
      thumbnail_icons.style.opacity = '20%';
  }

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

  function configure_video(node) {
    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;
  }

  function useful_beta_link(pathname) { // idea and some code donated by Octopus Hugger
    const params = new URL(window.location.href).searchParams;
    const betaLink = new URL('https://beta.sankakucomplex.com/');

    // post index/page, simply replace chan with beta and keep search parameters
    if (pathname === '/' || pathname.startsWith('/post/show')) {
      betaLink.pathname = pathname;
      betaLink.search = window.location.search;

    // user page, /user/show/<id> or /user/show?name=<username> -> /user/show?name=<username>
    } else if (pathname.startsWith('/user/show')) {
      const username = document.querySelector('.user-show-heading > h2')?.innerText.replaceAll(' ', '_');

      betaLink.pathname = '/user/show';
      betaLink.searchParams.set('name', username);

    // wiki, /wiki/show?title=<entry> -> /tag/en?tagName=<entry>
    } else if (pathname.startsWith('/wiki/show')) {
      betaLink.pathname = '/tag/en';
      betaLink.searchParams.set('tagName', params.get('title'));

    // pool, /pool/show/<id> -> /books/<id>
    } else if (pathname.startsWith('/pool/show/')) {
      betaLink.pathname = '/books/' + pathname.substring('/pool/show/'.length);
    }

    // update beta link
    for (const a of document.querySelectorAll('#navbar a[href="https://beta.sankakucomplex.com/"]')) {
      a.href = betaLink.href;
    }
  }


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

  class IndexPage {
    static has_tag_scripts;

    static init() {
      IndexPage.has_tag_scripts = (document.querySelector('#mode > option[value=apply-tag-script]') !== null);
    }
  }

  function select_mode(mode) {
    const mode_dropdown = document.getElementById('mode');

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

    PostModeMenu_change_override();
  }

  function add_mode_options() {
    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) return; // not logged in

    const add_mode_option = (text, value) => {
      const option = document.createElement('option');
      option.text  = text;
      option.value = value;
      mode_dropdown.add(option);
    };

    if (IndexPage.has_tag_scripts) {
      add_mode_option('Choose Parent', 'choose-parent');
      add_mode_option('Set Parent', 'set-parent');
    }
    add_mode_option('Edit Tags', 'edit-tags');
    add_mode_option('Find Similar', 'find-similar');
    if (IndexPage.has_tag_scripts) add_mode_option('Delete Post', 'delete'); // rough estimate of user permissions

    override_mode_change_event(mode_dropdown);

    PostModeMenu_init_workaround(); // guarantee that 'mode' correctly changes to new modes when loading page
  }

  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>
<h5><a id="post-link" href="" target="_blank"></a></h5>
<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" style="white-space: nowrap"><input accesskey="s" name="commit" tabindex="11" type="submit" value="Save changes" style="min-width: 13.5em;">
</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="SA-post_tags">Tags</label>
</th>
<td>
<textarea cols="83" id="SA-post_tags" name="post[tags]" rows="9" spellcheck="false" tabindex="10" autocomplete="off" style="margin-top: 0.5em;" ></textarea>
</td>
</tr>
</tbody>
</table>
</form>
</div>
</div>`;

    document.body.appendChild(dialog);
    add_tag_buttons('SA-edit-form');

    // hide when clicking outside
    document.addEventListener('click', (e) => {
      if (is_post_edit_dialog_visible()) {
        if (e.target.closest('#post_edit_dialog, #autosuggest') !== null)
          return; // clicked inside

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

    enable_auto_suggest();
  }

  async function enable_auto_suggest() {
    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?.style.display === 'block';
  }

  function open_post_edit_dialog(thumb) {
    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);
    if (post_id === null) return;

    const a = thumb.querySelector('a');
    if (a === null) 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.querySelector('.preview').src;
    post_link.href = a.href;
    post_link.innerText = 'Post ' + post_id;

    const full_rating = (rating) => ({ 'g': 'safe', 'r15+': 'questionable', 'r18+': 'explicit' }[rating]);

    // get tags and rating
    const post = get_post_from_thumb(thumb);
    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;
    }

    update_tag_elements();

    form.onsubmit = async (event) => {
      event.preventDefault(); // block reloading page

      delete_useless_tags_tag();

      const submitted_tags = new Tags(post_tags.value).toArray();
      show_notice(console.log, '[addon] saving...');

      // manually submit data
      try {
        const response = await fetch(new URL(`/post/update/${post_id}`, document.location), {
          method: 'POST',
          body: new FormData(form),
          redirect: 'manual', // this will otherwise err because of a https -> http redirect
        });

        // we assume success on redirect
        if (response.type === 'opaqueredirect' || response.ok) {
          // update local tags
          post.tags = submitted_tags;
          deletion_sanity_checks();

          show_notice(console.log, '[addon] saved tags!');
        } 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');
      const script_presets = document.getElementById('tagscript_presets_dropdown');
      if (mode_dropdown === null) return;
      if (e.ctrlKey || e.altKey || e.shiftKey) return;

      if (e.target === mode_dropdown || (script_presets !== null && e.target === script_presets)) {
        e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts'
      } else if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) {
        return;
      }

      for (const hotkey of config.indexpage_hotkeys) {
        hotkey.call(e);
      }
    }, true);
  }

  function PostModeMenu_init_workaround() {
    // 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);
      document.getElementById('mode').value = mode;
    }

    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;
    }

    // setting mode failed, possible when changing to account with lower permissions
    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: luminance 40
      document.body.style.backgroundColor = (darkmode ? '#500050' : '#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!');
        select_mode('choose-parent');
      } else {
        document.body.style.backgroundColor = (darkmode ? '#005050' : '#DFF');
      }
    } else if (s === 'edit-tags') {
      document.body.style.backgroundColor = (darkmode ? '#006400' : '#3A3');
    } else if (s === 'find-similar') {
      document.body.style.removeProperty('background-color');
    } else if (s === 'delete') {
      document.body.style.backgroundColor = 'rgba(147, 0, 0, 0.7)';
    }
  }

  function PostModeMenu_click_override(event) {
    const thumb_a = event.currentTarget;
    const thumb = thumb_a.parentElement;
    const post_id = get_thumbnail_post_id(thumb);

    if (unsafeWindow.PostModeMenu.click(post_id))
      return true; // view mode, let it click

    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' : ''));
    } else if (s === 'edit-tags') {
      open_post_edit_dialog(thumb);
    } else if (s === 'find-similar') {
      open_in_tab(window.location.origin + '/post/similar?id=' + post_id);
    } else if (s === 'delete') {
      open_in_tab(window.location.origin + '/post/delete/' + post_id);
    }

    return false;
  }

  function override_mode_change_event(mode_dropdown) {
    mode_dropdown.removeAttribute('onchange');
    mode_dropdown.onchange = PostModeMenu_change_override;
  }

  function override_thumbnail_click_event(thumb_a) {
    thumb_a.removeAttribute('onclick');
    thumb_a.onclick = PostModeMenu_click_override;
  }

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

  // TODO expand this
  class PostPage {
    static post_id = null;
    static parent_id = null;

    static init() {
      PostPage.post_id = document.getElementById('hidden_post_id')?.innerText;
      PostPage.parent_id = document.getElementById('post_parent_id')?.value;
    }
  }

  // original post/parent ids
  let image_data = null;
  let resize_timer;
  let tag_update_timer;
  // set by find_actions_list():
  let found_delete_action = false;

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

  class TagMenuScaler {
    static #mouse_moved = false;

    static mousedown(e) {
      e.preventDefault();
      TagMenuScaler.#mouse_moved = false;
      window.addEventListener('mousemove', TagMenuScaler.mousemove);
      window.addEventListener('mouseup',   TagMenuScaler.mouseup);
    }

    static mousemove(e) {
      e.preventDefault();
      TagMenuScaler.#mouse_moved = true;
      TagMenuScaler.set_scale(e, false);
    }

    static mouseup(e) {
      e.preventDefault();
      if (TagMenuScaler.#mouse_moved) TagMenuScaler.set_scale(e, true);

      window.removeEventListener('mousemove', TagMenuScaler.mousemove);
      window.removeEventListener('mouseup',   TagMenuScaler.mouseup);
    }

    static set_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', TagMenuScaler.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();
      delete_useless_tags_tag();
      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.removeProperty('display');
    }

    const create_tag_button = function(tag, skip_common_tags_update = false) {
      const a = document.createElement('A');
      a.href = '#';
      a.style.backgroundColor = (is_darkmode() ? '#000' : '#FFF'); // more contrast for tag buttons
      a.classList.add('tag_button');
      if (config.tag_categories.has(tag)) a.classList.add(`tag-type-${config.tag_categories.get(tag)}`);
      if (!get_post_tags().has(tag)) a.classList.add('tag_nonexistent');

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

        if (get_post_tags().has(tag)) {
          remove_post_tag(tag, skip_common_tags_update);
          a.classList.add('tag_nonexistent');
        } else {
          add_post_tag(tag, skip_common_tags_update);
          a.classList.remove('tag_nonexistent');
        }
        return false;
      };
      a.innerText = tag;
      return a;
    };

    const create_tag_list = function() {
      const div = document.createElement('DIV');
      div.className = 'tag_list';
      return div;
    };

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

    // 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(new Tags(list_tag[j][0]).toArray());
          }

          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)><table><tr><td><div (button)>
          // TODO maybe replace with grid
          const table = document.createElement('TABLE');
          table.style.display = 'inline-block';
          group_style(table);
          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(create_tag_button(tags_table[row][col], true));
              tr.appendChild(td);
            }
            table.appendChild(tr);
          }

          list_flex.appendChild(table);
        } 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 (flexbox)><div (button)>
          const group_div = document.createElement('DIV');
          group_div.className = 'tag_group';
          group_style(group_div);

          for (const tag of tags)
            group_div.appendChild(create_tag_button(tag, true));

          list_flex.appendChild(group_div);
        } else /* if (tags_type === tag_types.LIST) */ {
          // <div (flexbox)><div (button)>
          const tags = new Tags(list_tag);
          for (const tag of tags) {
            list_flex.appendChild(create_tag_button(tag, true));
          }
        }
      }

      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 = get_post_tags_el();
    if (post_tags === null) return; // not logged in

    const delayed_update = () => {
      clearTimeout(tag_update_timer);
      tag_update_timer = setTimeout(update_tag_elements, 400);
    };

    post_tags.addEventListener('change', delayed_update);
    post_tags.addEventListener('input',  delayed_update);
  }


  function add_tags_submit_listener() {
    document.getElementById('edit-form')?.addEventListener('submit', () => {
      delete_useless_tags_tag();
    });
  }

  function find_actions_list() {
    let actions_ul;

    for (const a of document.querySelectorAll('.sidebar > div > ul:not(#tag-sidebar) > li > a')) {
      try {
        const pathname = new URL(a.href).pathname;
        if (pathname.startsWith('/post/similar')) {
          actions_ul = a.parentElement.parentElement;
        } else if (pathname.startsWith('/post/delete/')) {
          found_delete_action = true;
        }
      } catch (ignored) {}
    }

    return actions_ul;
  }

  function copy_translations() {
    config.notes = [];

    for (const note of unsafeWindow.Note.all) {
      config.notes.push({
        text: note.old.raw_body,
        fullsize: note.fullsize,
      });
    }

    save_setting('notes', config.notes);
    show_notice(console.log, '[addon] copied notes');
  }

  function paste_translations() {
    show_notice(console.log, '[addon] pasted notes');

    // TODO check https://chan.sankakucomplex.com/wiki/show?title=about%3Anote_formatting
    // TODO save all notes

    for (const note of config.notes) {
      unsafeWindow.Note.create();
      const new_note = unsafeWindow.Note.all.at(-1);

      const box = new_note.elements.box;
      const image = new_note.elements.image;
      const ratio = new_note.ratio();

      // set text
      new_note.elements.body.innerText = note.text;
      new_note.old.raw_body = note.text;

      // set size
      const width  = note.fullsize.width  * ratio;
      const height = note.fullsize.height * ratio;
      box.style.width  = width  + 'px';
      box.style.height = height + 'px';

      // set position
      const clipX = (x) => Math.max(5, Math.min(x, image.clientWidth  - box.clientWidth  - 5));
      const clipY = (y) => Math.max(5, Math.min(y, image.clientHeight - box.clientHeight - 5));
      const top  = clipY(note.fullsize.top  * ratio);
      const left = clipX(note.fullsize.left * ratio);
      box.style.top  = top  + 'px';
      box.style.left = left + 'px';
    }
  }

  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(); }, 'Fit image',              'scale-image-fit');
    add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(); }, 'Fit image (Horizontal)', 'scale-image-hor');
    add_action(() => { scale_image(SCALE_MODES.VERTICAL,   true); scroll_to_image(); }, 'Fit image (Vertical)',   'scale-image-ver');
    add_action(() => { scale_image(SCALE_MODES.RESET,      true); scroll_to_image(); }, 'Reset image size',       'reset-image');

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

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

    add_action(copy_translations,  'Copy Notes',  'copy-notes');
    add_action(paste_translations, 'Paste Notes', 'paste-notes');
  }

  function add_flagger_links() {
    for (const a of document.querySelectorAll('.flag-and-reason-count > a')) {
      const username = a.innerText;

      const flagged = document.createElement('A');
      flagged.innerText = 'flagged';
      flagged.href = get_search_url(`flagger:${username}`);

      a.insertAdjacentText('afterend', ')');
      a.insertAdjacentElement('afterend', flagged);
      a.insertAdjacentText('afterend', ' (');
    }
  }

  function link_to_post_tag_history() {
    if (PostPage.post_id === null) return;

    for (const a of document.querySelectorAll('#subnavbar > li > a')) {
      try {
        const url = new URL(a.href);

        if (url.pathname.startsWith('/post_tag_history/index')) {
          url.search = 'post_id=' + PostPage.post_id;
          a.href = url;
        }

      } catch (ignored) {}
    }
  }

  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.querySelector('table');

      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(form_id) {
    const edit_form = document.getElementById(form_id);
    if (edit_form === null) return; // not logged in

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

    const parent_el = document.getElementById('post_parent_id');

    {
      const el = create_button();
      el.id = 'clear_parent_id_button';
      el.style.margin = '0 3px 0 6px';
      el.innerText = 'Clear';
      el.onclick = () => { parent_el.value = ''; return false; };
      parent_el?.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; };
      parent_el?.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; };
      tag_button_place.appendChild(el);
    }

    const append_tag_button = (id, tag) => {
      const el = create_button();
      el.id = id;
      el.className = 'SA-tag-button';
      el.dataset['tag'] = tag;
      el.style.margin = '0 3px';
      el.onclick = () => { toggle_post_tag(tag); return false; };
      tag_button_place.appendChild(el);
    };

    append_tag_button('tag_dup_button',     'duplicate');
    append_tag_button('tag_var_button',     'legitimate_variation');
    append_tag_button('tag_rev_button',     'revision');
    append_tag_button('tag_has_rev_button', 'has_revised_version');
    append_tag_button('tag_pot_button',     'potential_duplicate');
  }

  function update_tag_buttons() {
    const taglist = get_post_tags_el();
    if (taglist === null)
      return;

    const tags = get_post_tags();

    for (const button of document.querySelectorAll('.SA-tag-button')) {
      const tag = button.dataset['tag'];

      if (tag === 'potential_duplicate') {
        if (tags.has(tag)) {
          button.disabled = false;
          button.style.cursor = 'pointer';
        } else {
          button.disabled = true;
          button.style.removeProperty('cursor');
        }
      }

      button.innerText = (tags?.has(tag) ? '-' : '+') + tag;
    }
  }

  function reset_parent_id() {
    document.getElementById('post_parent_id').value = PostPage.parent_id;
  }

  function get_post_old_tags_el() {
    return document.querySelector('#post_old_tags, #SA-post_old_tags');
  }

  function get_post_tags_el() {
    return document.querySelector('#post_tags, #SA-post_tags');
  }

  function get_post_old_tags() {
    return new Tags(get_post_old_tags_el()?.value);
  }

  function get_post_tags() {
    return new Tags(get_post_tags_el()?.value);
  }

  function toggle_post_tag(tag, skip_common_tags_update = false) {
    if (get_post_tags().has(tag)) {
      remove_post_tag(tag, skip_common_tags_update);
    } else {
      add_post_tag(tag, skip_common_tags_update);
    }
  }

  function add_post_tag(tag, skip_common_tags_update = false) {
    const tags_el = get_post_tags_el();
    const tags = get_post_tags();

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

    tags.add(tag);
    tags_el.value = tags.toString() + ' ';

    update_tag_elements(skip_common_tags_update);
  }

  function remove_post_tag(tag, skip_common_tags_update = false) {
    const tags = get_post_tags();

    tags.remove(tag);
    get_post_tags_el().value = tags.toString() + ' ';

    update_tag_elements(skip_common_tags_update);
  }

  function delete_useless_tags_tag() {
    if (config.editform_deleteuselesstags) remove_post_tag('useless_tags');
  }

  function reset_tags() {
    get_post_tags_el().value = get_post_old_tags_el().value;
    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(post_id, reason_suffix) {
    if (post_id === null) {
      show_notice(console.error, '[addon] no post id, report to author!');
      return;
    }
    if (PostPage.parent_id === null) {
      show_notice(console.warn, '[addon] parent id not found, not logged in?');
      return;
    }

    const current_parent_id = document.getElementById('post_parent_id')?.value;
    if (current_parent_id !== PostPage.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_post_tags();
    const old_tags = get_post_old_tags();
    if (tags.has('duplicate') && !old_tags.has('duplicate')) {
      show_notice(console.warn, '[addon] duplicate tag set but not saved!');
      return;
    }
    if (!old_tags.has('duplicate')) {
      show_notice(console.warn, '[addon] not tagged as duplicate!');
      return;
    }

    if (old_tags.has('legitimate_variation') || old_tags.has('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 ${PostPage.parent_id}${reason_suffix}`);
    if (reason === null)
      return;

    (async function() {
      try {
        const response = await fetch(new URL('/post/flag.json', document.location), {
          method: 'POST',
          body: new URLSearchParams({ id: post_id, reason }),
        });

        if (!response.ok) {
          show_notice(console.error, `[addon error] non-OK status code ${response.status}`);
          return;
        }

        const data = await response.json();

        if (data.success) show_notice(console.log, 'Post was resent to moderation queue');
        else              show_notice(console.error, `Error: ${data.reason}`);
      } catch (error) {
        show_notice(console.error, '[addon error] error flagging post!', error);
      }
    })();
  }

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

      return;
    }

    const 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() {
    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() {
    window.requestAnimationFrame(() => {
      if (image_data === null) return;
      const absolute_img_top = Math.round((image_data.is_flash ? image_data.non_img_div : image_data.img_elem).getBoundingClientRect().top) + window.scrollY;
      if (config.scroll_to_image_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, { 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, { 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.replaceAll(',', '');
        size = parseInt(size, 10);

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

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

    // mimic sitescript
    unsafeWindow.jQuery('a#image-link.sample').unbind('click').removeAttr('href');
    unsafeWindow.Post.highres();
  }

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

    const url = new URL(img.src);
    if (url.host === 'v.sankakucomplex.com') {
      url.host = 's.sankakucomplex.com';
      img.src = url;
    }
  }

  function add_postpage_hotkeys() {
    document.addEventListener('keydown', (e) => {
      if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;

      for (const hotkey of config.postpage_hotkeys) {
        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_source_link() {
    const stats = document.getElementById('stats');
    if (stats === null) return;

    for (const link of stats.getElementsByTagName('A')) {
      if (!link.href) continue;
      if (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) { }
      } else {
        const match = /https:\/\/pictures.hentai-foundry.com\/[^/]\/([^/]+)\/(\d+)\//.exec(link.href);
        if (match) {
          const [,user, post_id] = match;
          link.href = `https://www.hentai-foundry.com/pictures/user/${user}/${post_id}`;
        }
      }
    }
  }

  /***********************/
  /* 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_status_post_links() {
    // TODO https://chan.sankakucomplex.com/user/show/1562990

    const random_posts = [...document.querySelectorAll('.highlightable a')].find(a => a.href.endsWith('%20order%3Arandom'));
    if (!random_posts) return;
    const user_posts = random_posts.parentElement.children[0].href;
    const username = new URL(user_posts).searchParams.get('tags').substring('user:'.length);

    const status_post_link = (name, search) => {
      const a = document.createElement('A');
      a.href = get_search_url(search);
      a.innerText = name;
      return a;
    };

    let dateSearch = '';
    if (get_username() !== username) { // not your own profile
      // Thanks to Evaera
      // add leeway of 3 days to tag count searches (should be 2 days but date search goes by server time, which can differ from local UTC by up to 14 hours)
      const threeDaysAgo = new Date();
      threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
      const dd = String(threeDaysAgo.getUTCDate()).padStart(2, '0');
      const mm = String(threeDaysAgo.getUTCMonth() + 1).padStart(2, '0');
      const yyyy = threeDaysAgo.getUTCFullYear();
      dateSearch = ` date:<${yyyy}-${mm}-${dd}`;
    }

    insert_node_after(status_post_link('approver', `approver:${username}`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
    insert_node_after(status_post_link('flagger', `flagger:${username}`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
    insert_node_after(status_post_link('artist_tag_count:0', `user:${username} artist_tag_count:0${dateSearch}`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
    insert_node_after(status_post_link('general_tag_count:<13', `user:${username} general_tag_count:<13${dateSearch}`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
    insert_node_after(status_post_link('tagme', `user:${username} tagme${dateSearch}`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
    insert_node_after(status_post_link('flagged', `user:${username} status:flagged`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
    insert_node_after(status_post_link('unapproved', `user:${username} status:pending`), random_posts);
    insert_node_after(document.createTextNode(', '), random_posts);
  }

  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() {
    if (!IndexPage.has_tag_scripts) return;

    const mode_menu = document.getElementById('mode-menu');
    if (mode_menu === null) {
      show_notice(console.error, '[addon error] couldn\'t find "Mode" menu, disabled tagscript 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);
      select_mode('apply-tag-script');
    };

    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() { // add a "⚙" link to the tag edit page
    try {
      const h2 = document.querySelector('.title');

      const tag = new URL(window.location.href).searchParams.get('title');

      const a = document.createElement('A');
      a.href = '/tag/edit?name=' + tag;
      a.innerText = '⚙';
      a.title = 'Edit Tag';

      h2.appendChild(a);
    } catch (error) {
      show_notice(console.error, '[addon error] couldn\'t add "⚙" tag page link, check console', error);
    }
  }

  function add_tag_history_link() { // add a "History »" link to the tag history page
    try {
      const info = document.querySelector('.tag-information');
      const related = document.querySelector('.related-tags');

      // use the hidden form field because url can either be e.g. /tag/edit?name=high_resolution or /tag/edit/464292
      const tag = document.getElementById('tag_name').value;

      const div = document.createElement('DIV');
      const h4 = document.createElement('H4');
      const a = document.createElement('A');
      a.href = '/tag_history?tag_name=' + tag;
      a.innerText = 'History »';

      div.appendChild(h4);
      h4.appendChild(a);

      if (related !== null) {
        related.insertAdjacentElement('afterend', div);
      } else {
        info.prepend(div);
      }
    } catch (error) {
      show_notice(console.error, '[addon error] couldn\'t add tag history page link, check console', error);
    }
  }

  function deletion_sanity_checks() { // for duplicates
    const thumbs = [...document.querySelectorAll('#content > .deleting-post .thumb')];
    if (thumbs.length !== 2)
      return; // no parent

    const WARNING_TEXT = '<b style="color: crimson">Warning: </b>';
    const CHECKED_TAGS = ['upscaled', 'legitimate_variation', 'revision', 'third-party_edit', 'decensored', 'potential_upscale', 'md5_mismatch', 'resolution_mismatch'];
    const CHECKED_COMBINATIONS = [['censored', 'uncensored']];

    // remove old warnings before re-evaluating
    document.querySelectorAll('.SA-warning').forEach((el) => el.remove());

    const posts = thumbs.map(get_post_from_thumb);

    const warn_tags = posts.map(post => post.tags.filter(tag => CHECKED_TAGS.includes(tag)));

    for (const [tag1, tag2] of CHECKED_COMBINATIONS) {
      for (let i = 0; i < 2; i++) {
        if (posts[i].tags.includes(tag1) && posts[1 - i].tags.includes(tag2)) {
          warn_tags[i].push(tag1);
          warn_tags[1 - i].push(tag2);
        }
      }
    }

    const convert_to_margin = (thumb) => {
      const a = thumb.querySelector('a');
      const y_diff = a.getBoundingClientRect().top - thumb.getBoundingClientRect().top;

      // the thumbnail image is centered in a grid (see adjust_css()), which will move it up when something is added below it
      // to fix this we replace the vertical centering with a margin (and allow the thumbnail to scale vertically too)

      // fix thumbnail in place vertically
      a.style.marginTop = `${y_diff}px`;
      thumb.style.alignContent = 'start';
      // make thumbnail scale vertically
      thumb.style.minHeight = window.getComputedStyle(thumb).height;
      thumb.style.height = 'auto';
    };

    thumbs.forEach(convert_to_margin);

    const add_tags_below_thumb = (thumb, tags, missing) => {
      for (const tag of tags) {
        const span = document.createElement('SPAN');
        span.className = 'SA-warning';
        span.style.color = 'crimson';
        span.style.fontWeight = 'bold';
        if (missing) span.style.textDecoration = 'line-through';
        span.innerText = tag;

        thumb.appendChild(span);
      }
    };

    for (let i = 0; i < thumbs.length; i++) {
      add_tags_below_thumb(thumbs[i], warn_tags[i]);
    }

    if (!posts[0].tags.includes('duplicate')) {
      add_tags_below_thumb(thumbs[0], ['duplicate'], true);
    }

    const integer_multiple = (a, b) => {
      if (a < b) return integer_multiple(b, a);
      if (a > b && a % b === 0) {
        return a / b;
      }
      return NaN;
    };

    const widths = [];
    const heights = [];

    // read resolutions
    const res = document.querySelector('#content > .deleting-post > ul > li:nth-child(2)');
    for (const b of res.getElementsByTagName('B')) {
      const match = /([\d]+)x([\d]+)/.exec(b.innerText);
      if (match) {
        const [, width, height] = match;
        widths.push(Number(width));
        heights.push(Number(height));
      }
    }

    // add potential upscale warning
    const multiple = integer_multiple(...widths);
    if (multiple === integer_multiple(...heights)) {
      const span = document.createElement('SPAN');
      span.className = 'SA-warning';
      span.innerHTML = ` ${WARNING_TEXT} potential ${multiple}x upscale`;
      res.insertAdjacentElement('beforeend', span);
    }
  }

  function add_custom_duplicate_delete_reason() {
    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 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);

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

      const is_custom_dupe = reason.options[i] === custom_dupe_option;

      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.replaceAll(' ', '_');
      if (['duplicate', 'potential_duplicate'].includes(tag)) continue; // TODO: ignore all meta tags?
      tags_diff += tag + ' ';
    }

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

    tags_not_present.appendChild(button);
  }

  function add_post_edit_buttons() {
    // cache thumbnail tags
    for (const thumb of document.querySelectorAll('.thumb'))
      get_post_from_thumb(thumb);

    const thumbs = [...document.querySelectorAll('.deleting-post .thumb')];
    for (const thumb of thumbs) {
      const a = document.createElement('A');
      a.innerText = '⚙';
      a.style.fontSize = '120%';
      a.href = '#';
      a.onclick = () => {
        open_post_edit_dialog(thumb);
        return false;
      };
      thumb.appendChild(a);
    }
  }

  function add_moderation_search_template() {
    const query = document.getElementById('query');
    const select = create_template_dropdown(new Map([
      ['Pending Posts', {add: ['status:pending'], remove: ['order:recently_flagged']}],
      ['Flagged Posts', {add: ['order:recently_flagged', '-status:pending'], remove: []}]
    ]), (value) => {
      select.selectedIndex = 0;

      const search_tags = new Tags(query.value);
      for (const tag of value.remove) {
        search_tags.remove(tag);
      }
      for (const tag of value.add) {
        search_tags.add(tag);
      }

      query.value = search_tags.toString() + ' ';
      query.focus();
    });

    const search_button = document.querySelector('#content > form > input:nth-child(2)');
    search_button.insertAdjacentElement('afterend', select);
    search_button.insertAdjacentText('afterend', ' ');
  }

  function get_lang_path() {
    let lang = document.getElementById('lang-sel')?.value;

    if (lang === null && new URL(document.location).pathname[3] === '/') {
      lang = new URL(document.location).pathname.slice(0, 3);
    }

    if (lang === null || lang === '/en') lang = '';

    return lang;
  }

  function old_wiki_tag_links() {
    if (!config.use_old_wiki) return;

    const lang = get_lang_path();
    if (lang === '/ja') return; // old wiki cannot be accessed by japanese tag names (and there seems to be no way to get the english tag names)

    const revert_beta_link = (a, is_tag_edit_link) => {
      try {
        const url = new URL(a.href);
        if (url.hostname !== 'beta.sankakucomplex.com') return;
        const tag = url.searchParams.get('tagName');

        // tag index "Edit" link
        if (is_tag_edit_link) {
          if (url.pathname.startsWith('/tag/')) {
            a.href = new URL(`${lang}/tag/edit?name=${tag}`, document.location).href;
            return;
          }
        }

        if (url.pathname.startsWith('/tag/history')) {
          a.href = new URL(`${lang}/tag_history?tag_name=${tag}`, document.location).href;
        } else if (url.pathname.startsWith('/tag/')) { // note: coincides with "Edit" link
          a.href = new URL(`${lang}/wiki/show?title=${tag}`, document.location).href;
        } else if (url.pathname === '/') {
          const tags = url.searchParams.get('tags');
          a.href = new URL(`${lang}/?tags=${tags}`, document.location).href;
        }
      } catch (e) { console.error('[addon error] failed reverting beta link', a.href, e); }
    };

    if (General.page === Page.TagIndex) {
      document.querySelectorAll('tbody td:nth-child(6) a').forEach(a => revert_beta_link(a, true));
      document.querySelectorAll('tbody a').forEach(revert_beta_link);
    } else {
      document.querySelectorAll('.tooltip a').forEach(revert_beta_link);
    }
  }

  function old_wiki_subnav_links() {
    if (!config.use_old_wiki) return;

    const lang = get_lang_path();
    const tag = new URL(document.location).searchParams.get('title');
    if (!tag) {
      show_notice(console.error, '[addon error] cannot find wiki tag');
      return;
    }

    for (const a of document.querySelectorAll('#subnavbar a')) {
      const url = new URL(a.href);
      if (url.hostname !== 'beta.sankakucomplex.com') continue;

      if (a.pathname === '/wiki/edit_article') {
        a.href = new URL(`${lang}/wiki/edit?title=${tag}`, document.location).href;
      } else if (a.pathname === '/wiki/article_history') {
        a.href = new URL(`${lang}/wiki/history?title=${tag}`, document.location).href;
      } else if (a.pathname === '/wiki/create_article') {
        a.href = new URL(`${lang}/wiki/add?title=${tag}`, document.location).href;
      } else if (a.pathname === '/') {
        a.href = new URL(`${lang}/?tags=${tag}`, document.location).href;
      }
    }
  }

  function old_pools_post_link() {
    if (!config.use_old_pools) return;

    for (const el of document.querySelectorAll('.status-notice')) {
      if (el.id.startsWith('pool')) {
        const pool_id = el.id.substring(4);

        for (const a of el.querySelectorAll('a')) {
          const url = new URL(a.href);
          if (url.hostname !== 'beta.sankakucomplex.com') continue;

          a.href = new URL(`${get_lang_path()}/pool/show/${pool_id}`, document.location).href;
          break;
        }
      }
    }
  }

  function old_nav_links() {
    for (const a of document.querySelectorAll('#navbar a')) {
      const url = new URL(a.href);
      if (url.hostname !== 'beta.sankakucomplex.com') continue;

      let reset_color = false;

      if (config.use_old_pools && a.pathname === '/books') {
        a.href = new URL(`${get_lang_path()}/pool/index`, document.location).href;
        reset_color = true;
      } else if (config.use_old_tags_index && a.pathname === '/tags') {
        a.href = new URL(`${get_lang_path()}/tag/index`, document.location).href;
        reset_color = true;
      }

      if (reset_color) {
        const font = a.querySelector('font');
        if (font) font.style.color = 'unset';
      }
    }
  }


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

  await load_config();

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

  // detect page
  if (pathname === '/' || pathname.startsWith('/post/similar'))                       General.page = Page.Index;
  else if (pathname.startsWith('/posts/') || pathname.startsWith('/post/show/'))      General.page = Page.Post;
  else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit'))     General.page = Page.Wiki;
  else if (pathname.startsWith('/wiki/show'))                                         General.page = Page.WikiShow;
  else if (pathname.startsWith('/tag/edit'))                                          General.page = Page.Tag;
  else if (pathname.startsWith('/tag/index') || ['/tag/', '/tag'].includes(pathname)) General.page = Page.TagIndex;
  else if (pathname.startsWith('/post/delete'))                                       General.page = Page.Delete;
  else if (pathname.startsWith('/post/moderate'))                                     General.page = Page.Moderate;
  else if (pathname.startsWith('/user/show'))                                         General.page = Page.User;
  else if (pathname.startsWith('/user_record/create'))                                General.page = Page.Record;

  // listen for config changes in other windows
  add_storage_change_listener();

  modify_css();

  // add thumbnail icons and fade out thumbnails
  modify_nodes('span.thumb .preview', modify_thumbnail, '.content-page, #recommendations');

  switch (General.page) {
    case Page.Post:

      // mute/pause video
      modify_nodes('video#image', node => { configure_video(node); return true; });

      break;
  }




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

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

    update_headerlogo();
    useful_beta_link(pathname);
    old_wiki_tag_links();
    old_nav_links();

    switch (General.page) {
      case Page.Index:
        IndexPage.init();

        add_mode_options();
        add_tagscript_presets();

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

        add_post_edit_dialog();
        update_tag_elements(); // initialize tag menu/buttons
        add_tags_change_listener();

        break;

      case Page.Post:
        PostPage.init();

        // reading the post id can fail when rate limited (or when the website changes)
        if (config.view_history_enabled && PostPage.post_id !== null) {
          config[HISTORY_KEY].add(Number(PostPage.post_id));
          save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history
        }

        collect_tag_categories();
        if (config.tag_search_buttons) add_tag_search_buttons();
        if (config.tag_category_collapser) add_tag_category_collapser();
        add_addon_actions(find_actions_list());

        add_flagger_links();
        link_to_post_tag_history();

        fix_source_link();
        if (config.move_stats_to_edit_form) move_stats_to_edit_form();

        add_tag_buttons('edit-form');
        if (config.tag_menu) add_tag_menu();
        update_tag_elements(); // initialize tag menu/buttons
        add_tags_change_listener();
        add_tags_submit_listener(); // specifically for edit-form

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

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

        old_pools_post_link();

        break;

      case Page.Wiki:
        add_dtext_style_buttons();
        add_wiki_template();

        break;

      case Page.WikiShow:
        add_tag_edit_gear();
        old_wiki_subnav_links();

        break;

      case Page.Tag:
        add_tag_history_link();

        break;

      case Page.TagIndex:
        old_wiki_tag_links();

        break;

      case Page.User:
        add_status_post_links();

        break;

      case Page.Record:
        add_record_template();

        break;

      case Page.Delete:
        document.getElementById('custom_reason').style.minWidth = '25%';
        add_custom_duplicate_delete_reason();
        add_tags_copy_button();

        add_post_edit_dialog();
        add_post_edit_buttons();
        update_tag_elements(); // initialize tag menu/buttons
        add_tags_change_listener();

        deletion_sanity_checks();

        break;

      case Page.Moderate:
        add_moderation_search_template();

        break;
    }
  }

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