private_lock / KGForum Support

// ==UserScript==
// @name        KGForum Support
// @namespace   kgforum.org
// @version     2017.12.24
// @copyright   2007-2017, private_lock (http://private-lock.bplaced.net)
// @description Diverse kleine Eingriffe in die Seiten des KGForum.org zur Verbesserung der Nutzerfreundlichkeit
// @creator     private_lock@yahoo.com
// @oujs:author private_lock
// @homepageURL https://www.kgforum.org/index_5.html
// @supportURL  http://private-lock.bplaced.net/kgforum
// @icon        http://private-lock.bplaced.net/kgforum/safe-sex_64x64.gif
// @downloadURL https://openuserjs.org/install/private_lock/KGForum_Support.user.js
// @updateURL   https://openuserjs.org/meta/private_lock/KGForum_Support.meta.js

// @include     https://*.kgforum.org/*
// @include     https://kgforum.org/*
// @include     http://*.kgforum.org/*
// @include     http://kgforum.org/*

// @include     https://www.keuschheitsforum.org/*
// @include     https://keuschheitsforum.org/*
// @include     http://www.keuschheitsforum.org/*
// @include     http://keuschheitsforum.org/*

// @include     https://www.keuschheitsforum.de/*
// @include     https://keuschheitsforum.de/*
// @include     http://www.keuschheitsforum.de/*
// @include     http://keuschheitsforum.de/*

// @grant GM.getValue
// @grant GM.setValue
// @license GPL-3.0
// ==/UserScript==

/*
Vorweg
------
JavaScript für Erweiterung GreaseMonkey im Internet-Browser Firefox auf den Web-Seiten des kgforum.org

Bitte installieren Sie: https://addons.mozilla.org/de/firefox/addon/greasemonkey
und laden Sie die @downloadURL mit diesem Skript anschließend erneut zur Installation.
Eine bebilderte Anleitung finden Sie auf: http://private-lock.bplaced.net/kgforum



Datenschutz-Erklärung
---------------------
Mir ist überaus deutlich bewusst, dass die Mehrheit der Menschen, die sich im BDSM-Umfeld bewegen, höllische Angst vor
einer unfreiwilligen Bloßstellung haben. Darum benutzen wir (ich eingeschlossen) Pseudonyme, um diese Facette unseres
Sexuallebens vor Familie und normalen Freunden zu verbergen. Ich verspreche hiermit, Ihre Privatsphäre nach Kräften zu
schützen! Die Verwendung dieses Skriptes soll nach allen technischen Regeln der Kunst nicht dazu gereichen, Sie
auffliegen zu lassen. Bitte lesen Sie im folgenden, welche kritischen Punkte es zu bedenken gibt und entscheiden dann,
ob die Benutzung für Sie in Frage kommt.

Zunächst arbeiten per se alle GreaseMonkey-Skripte lokal im Browser auf dem Rechner des Benutzers mit den Informationen,
die der Webserver gesendet hat. Daraus folgt, dass die Skripte nichts erfinden können, was in den ursprünglichen Seiten
nicht enthalten ist. Jedoch bietet die systematische Aufbereitung der Seiten einen besseren Überblick und leichteres
Verständnis für den Leser und mehr Komfort bei der Wahl der nächsten Aktion.

Als Beispiel wird zu jedem Datum der Wochentag eingesetzt. Würde in der Original-Webseite kein Datum stehen, kann eben
kein Wochentag bestimmt werden. Mit Kalender neben dem Bildschirm könnte der Benutzer auch dort alle Wochentage
nachschlagen. Es ist nur viel bequemer, dem Computer diese eintönige Aufgabe zu überlassen. Das kostet lediglich ein
wenig Rechenleistung unterm Schreibtisch des Benutzers, ohne dass der Server beeinträchtigt wird.

Einige URLs werden automatisch manipuliert. Aus dem zeitlichen Verhalten sowie den für den Server unbekannten Parametern
könnte zurück geschlossen werden, dass der Benutzer dieses Skript einsetzt. Die Auswertung wäre jedoch aufwändig und
obendrein ziemlich müßig. Die IP-Adresse des Benutzer ist dem Server eh bekannt, sonst könnten gar keine Daten geschickt
werden. Darüber hinaus geben viele sogar ihren Benutzernamen beim Login an und identifizieren sich so gegenüber dem
Server. Rein hypothetisch ginge es also lediglich um die zusätzliche Information, ob ein Benutzer so sehr am Forum
interessiert ist, dass er sogar ein Skript einsetzt, um die rauen Ecken etwas besser abzurunden?

Andererseits besteht für den Server-Betreiber forennet.org auch kein Anlass zur Sorge, denn es werden nur solche
Informationen abgerufen, die ohnehin veröffentlicht wurden. Dieses Skript verschafft dem Benutzer also keinerlei
Privilegien, die er ohne das Skript nicht auch haben könnte (eiserne Disziplin vorausgesetzt). Des weiteren werden
ausdrücklich keine Werbebanner entfernt, denn die finanzieren schließlich einen Großteil der Kosten, die das Forum
nun mal verursacht.

Etwa einmal pro Woche schaut das Skript nach, ob eine neue Version auf meiner Homepage vorliegt. Ich lade dort nur meine
persönliche Homepage hoch, habe aber ansonsten keine Rechte an dem Server. Eine Auswertung, wer wann oder wie oft das
Skript verwendet, findet nicht statt und wird es auch in Zukunft nicht geben. Wer mir nicht einmal soweit traut, sollte
sich hüten auf seinem Rechner Software auszuführen, die ich entwickelt habe.

Schließlich muss das Skript selbst auf Ihrem Rechner gespeichert werden. Außerdem legt es einige Einstellungen in Ihrem
Browser ab. D.h. selbst wenn Sie immer fleißig Ihre Historie löschen kann die Installation dieses Skriptes sowie die
abgelegten Einstellungen Sie als Leser des kgforum.org kompromittieren, sollte Ihr Rechner oder ein Backup Ihres
Firefox-Profils in falsche Hände geraten.



Zusammengefasst
---------------
1. Trauen Sie forennet.org nicht, dann sollten Sie deren Seiten einschließlich kgforum.org nicht benutzen.
2. Trauen Sie mir nicht, dann sollten sie meine Software und insbesondere dieses Skript nicht nutzen.
3. Fürchten Sie jemand anders hätte Zugriff auf Ihren Computer, sollten sie den Rechner nicht verwenden.



Funktionen
----------
- Automatische Weiterleitung auf eine feste Basis-URL
    erspart erneutes Anmelden auf allen Domains
    -> immer Zugriff auf beschränkte Bretter
    repariert teils kaputte Links in Zitaten
- Aussagekräftige, präzise Titel für Fenster bzw, Reiter
    gemeinsames Präfix KG
    gefolgt z.B. von dem Titel des Themas oder des Brettes
- Permanente direkte Links auf Beiträge
    nützlich, um auf andere Beiträge Bezug zu nehmen
    Nummerierung der Beiträge als zusätzliche Orientierung im Thema
- Annotation der Wochentage zum Einstelldatum
    stellt den Kontext her, wenn im Beitrag von "nächsten Freitag" die Rede ist
    funktioniert für Beiträge, private Nachrichten und Profile
- Anklickbare Webseiten in Profilen
    erspart es, den Text markieren und händisch in die Adressleiste kopieren zu müssen
- Such-Knopf nach allen Beiträgen in Profilen
    besser zu sehen, als der vorhandene Link
    liefert ca. 5 Mal mehr Ergebnisse als der vorhandene Link, die weiter in die Vergangenheit reichen
    -> schwankt je nach gesuchtem Benutzer
    -> entspricht dem manuellen Aufruf des Suchformulars unter Eingabe des Benutzernamens
- Löschlinks für Avatare unterm Bild im Profil -> TODO http://kgforum.org/display_5_2399_74116_760618.html#760618
- Suchergebnisse auch auf folgenden Seiten markieren und beim Blättern erhalten
    in einem mehr als 20 Beiträge langen Thema können so weitere Fundstellen auch auf anderen Seiten betrachtet werden
- Zeitstempel/Benutzer auswerten, um vom Suchergebnis direkt zum Beitrag zu springen
    bei mehrseitigen Themen wird automatisch die richtige Seite geladen
    funktioniert sowohl aus Suchergebnissen als auch aus der Brett-Übersicht heraus
- Hervorhebungen für nachträglich veränderte Beiträge
    falls nicht der ursprüngliche Einsteller den Text editiert hat
    -> Eingriff durch Moderatoren / Staffs
    falls mehr als 10 Minuten seit dem ersten Einstellen vergangen sind
    -> möglicherweise nicht nur Tippfehler behoben, sondern den Sinn verändert
    -> folgende Beiträge könnten sich auf den ursprünglichen Sinn beziehen
- Visualisieren von zeitlichen Lücken zwischen Beiträgen
    Dicke des Trennbalkens nimmt mit der Länge der Pause zu.
    Autoren von Beiträgen vor dem Trennbalken antworten evtl. nicht mehr oder revidieren alte Aussagen.
- Berechnung der Anzahl der Lesungen pro Beitrag in der Brett-Übersicht
- Persönliche Historie der bereits gelesenen Beiträge
    Wird ein Thema zum ersten Mal geöffnet oder länger als 60 Sekunden betrachtet, gilt es als gelesen.
    Der letzte Beitrag wird lokal im Browser vermerkt.
    Themen mit neuen Beiträgen, die noch nicht mindestens 60 Sekunden lang betrachtet wurden erscheinen grün.
    In der Übersicht eines Brettes werden beobachtete Themen, in denen sich seither nichts getan hat, türkis markiert.
    Unbeobachtete Themen oder solche, die älter sind bleiben grau.
    Letzte Beiträge je Forum und die neusten 40 in Übersichtsseite werden entsprechend Lesestatus eingefärbt.
    Unabhängig davon bleibt die Funktion der Forensoftware erhalten, ein gelb-schwarzes "new"
      hinter Beiträgen zu zeigen, die seit dem letzten Login noch nicht gelesen wurden.
    Einzelne Lesezeichen können im Thema manuell gelöscht werden (mit Sicherheitsabfrage)
- Aus der Brett-Übersicht direkt zum Lesezeichen springen, ohne die richtige Seiten wählen zu müssen.
- Einstellungen werden in eine SQLite-Datenbank im Installationsverzeichnis des Skriptes im Firefox-Profil gespeichert
    installiere Firefox-Erweiterung: https://addons.mozilla.org/de/firefox/addon/sqlite-manager
    Menü Hilfe > Informationen zur Fehlerbehebung > Profilverzeichnis > Ordner öffnen > Unterordner "gm_scripts"
    Menü Extras > SQLite Manager > neues Fenster
    Menü Datenbank > Mit Datenbank verbinden
        Filter von "SQLite-DB-Dateien (*.sqlite)" auf "Alle Dateien" stellen
        <Profilverzeichnis>/gm_scripts/KGForum_Support.db
    Die Daten für dieses Skript liegen in der Tabelle "scriptvals" (sortiert nach Spalte "name"):
        aenderungen_markieren   : 10    # Zeit in Minuten, ab der ein nachträglich editierter Beitrag markiert wird.
        beitraege_pro_seite     : 20
        font_size_offset        : 0     # Zahl etwa von 0 bis 3 mit der Bedeutung: größerer Wert = größere Schrift
        lese_verzoegerung       : 60    # Zeit in Sekunden, nach der eine Seite eines Themas als gelesen gilt.
        zuletzt_geschrieben     :       # Zeitstempel in Millisekunden seit 1.1.1970 zur Synchronisation mehrerer Tabs
        zzz_00 - zzz_ff         :       # Zeichenketten mit Komma-separierter Liste von zur Basis 36 kodierten Zahlen
            Beispiel            : zzz_01 = "...,9j 3 fbxp dfg0o,..."
            Themen-ID           : (9j)b36 * (100)b16 + (zzz_01)b16 = (9*36 + 19) * 256 + 1 = 87.809
            Anzahl-Beiträge     : (3)b36 = 3
            ID letzter Beitrag  : (fbxp)b36 = 15*36³ + 11*36² + 33*36 + 25 = 715.309
            Datum letzter Beitr.: (dfg0o)b36 = 13*36^4 + 15*36³ + 16*36² + 24 = 22.555.608 Minuten seit 1.1.1970 UTC
            http://kgforum.org/display_5__87809_715309.html#715309 am Mo 19.11.2012 15:48 GMT+1
    Außerdem finden sich dort überhaupt alle vom Skript gespeicherten Daten.
- Plus-Minus-Button für die Schriftgröße +A/a- -> TODO http://kgforum.org/display_5_2388_88090.html
- Zitate in vorangegangenen Beiträgen suchen (nur auf der gleichen Seite)
    Die Druckansicht enthält alle Beiträge des Themas und kann damit auch Zitate über Seitengrenzen zuordnen.
    Unterschiede einfärben, falls nicht zitierter unbekannter Text eingefügt wurde.
- Automatische Updates dieses Skriptes
    Einmal die Woche prüft GreaseMonkey in der Grundeinstellung, ob eine neue Version verfügbar ist.
- Dokumentation der Funktionen mittels Screenshots
    Als Gast anmelden -> ACHTUNG: Beim Abmelden wird das Heimverzeichnis des Gast in /tmp/guest-XXXXXX gelöscht
    Fenstergröße auf XGA 1024:768 -> nicht in die Ecke schieben, denn dann fehlen ein oder zwei Pixel in der Breite
    Fenster-Schatten beachten
    Galerie auf meine Homepage stellen
    Neues Thema. Erster Beitrag als Übersicht, jeder weitere Beitrag ein Kapitel mit Bild und Erklärung
    Zuerst im Moderatoren Brett vorstellen, Feedback der anderen einholen
- Statistik in der Brett-Übersicht (Summen: Themen / Beiträge)
- Beitrags und PN-Editor verbessert: (TODO https://www.kgforum.org/display_5_2416_83101_671063.html#671063)
    - an Cursorposition einfügen (function AddText() in https://www.kgforum.org/images/theme1/codebuttons.js)
    - Text-Auswahl beachten (im Editor)
    - Shadow, Glow & Flash rausnehmen
    - Durchgestrichen
    - Gedankenstrich
    - Formatierung zurücksetzen
    - leeres Tabellen-Gerüst einfügen
- Links in Beiträgen auf andere Threads einfärben und auf preferredDomain umbiegen
- Dialog-Modus für PN als Historie unter Antwort-Editor -> TODO http://kgforum.org/display_5_2416_92324.html



Ideen für weitere Versionen
---------------------------
- Diese Kommentare aus dem Skript auf die Webseite verpflanzen (trennen in private Mindmap und öffentlich)
- Alle Screenshots neu aufnehmen (z.B. geändertes Datumsformat mit 4-stelliger Jahreszahl)
- Link für "setze Lesezeichen bis hier her" hinterm Titel eines jeden Beitrages
- Dialog für Einstellungen via GM.registerMenuCommand()
- tagArray und imgArray refaktorieren und in XPath-Ausdrücke umwandeln
- gotoUnread blättert in der Brett-Übersicht / speichert Anzahl gelesener Themen
- Suchbegriffe auch erhalten, wenn durch das Brett geblättert wird
- Die preferredDomain aus dem ersten Aufruf lernen und konfigurieren als Einblendung in Einstellungen
- Vordergrund/Hintergrund/Unterstreichen für Links definieren -> TODO http://kgforum.org/display_5_2416_81098.html
- Lesezeichen als Ersatz für Themen-Abos -> TODO http://kgforum.org/display_5_2416_91456.html
- Sortiere Beiträge innerhalb eines Themas nach postId http://kgforum.org/display_5_2409_69300_512812.html#512812
- Threads in Suchergebnissen einfärben
- Zensierte Worte zählen in Geschichten, z.B. als Anteil aller Worte
- Schritfgröße durch onclick ohne Seite neu laden
- erste Abfrage liefert möglichereise nur neuste PN (gleiche Abfrage wiederholen, um auch alte zu sehen)
- Betreff einer PN in Editor einsetzen als Re, wenn Beitrags-ID & Beitrag bekannt
- Zitieren-Buttons fügen an Editor an (Lade als Unterseite und kopiere den Text)
- Zitate in den PN zuordnen
- Drag'N'Drop Griff, um die Breite = Zeilenlänge einer Beitragszelle zu verkleinern (und alle folgenden)
- gleiche Farbe erneut auf andere Selektion anwenden -> nicht nur auf selection-change hören



Bekannte Fehler:
----------------
- Keine Historie der PN unter PN-Editor -> XMLHttpRequest in GreaseMonkey ??? evtl. in die Seite injecten?
- async Aufrufe kehren sofort zurück -> Fehlermeldung unterdrückt & falsche Zeitmessung
*/



// anonyme Funktion um das Skript vorzeitig via Rücksprung abbrechen zu können.
// Außerdem ein bequemer Weg, alles in den strengen Modus zu heben
(async function() {
    "use strict";

var domainList =["kgforum.org",
        "keuschheitsforum.org",
        "keuschheitsforum.de",
    ],
    preferredDomain = domainList[0],
    preferredHost = "https://www." + preferredDomain;

// Springe immer automatisch auf die bevorzugte Domain
// TODO probiere Tag, dass das Skript ausführt, bevor die Seite geladen wurde
// http://wiki.greasespot.net/Metadata_Block
if ((      window.location.hostname != preferredHost.replace(/^.*\/\//, "")
        || window.location.protocol != preferredHost.replace(/\/\/.*$/, "")
    ) &&   window.location.protocol != "file:") {
    if (   window.location.hostname.search(/chat/i) < 0
        && window.location.hostname.search(/treffen/i) < 0
        && window.location.hostname.search(/directory/i) < 0
        && window.location.hostname.search(/settings/i) < 0
        ) {
        // location.replace(...) erzeugt keinen zusätzlichen Eintrag in der Historie des Browsers -> transparent
        window.location.replace(("" + window.location).replace(/^[a-z]*:\/\/[^\/]+\//, preferredHost + "/"));
    }
    return;
}


// erfasse Verarbeitungszeit
var startTime = new Date().getTime(), i, j;

var fehlermeldung = document.createElement("P");
fehlermeldung.innerHTML = "Fehler in KGForum_Support.user.js bitte melden an <a href='mailto:private_lock@yahoo.com"
    + "?subject=" + encodeURI("Fehler in KGForum_Support.user.js")
    + "&body=" + encodeURI("Fehler auf Seite:\n" + window.location
        + "\n\nFirefox Menü > Entwicklerwerkzeuge > Browser-Konsole meldet:\n???").replace(/[&#]/g, "@")
    + "'>private_lock</a>";
document.body.appendChild(fehlermeldung);

function logTime() {
    var now = new Date();
    fehlermeldung.innerHTML = "Zeit: " + now
        + " KGForum_Support.user.js: " + ((now.getTime() - startTime) / 1000) + " Sekunden";
}


// globale reguläre Ausdrücke statisch extrahiert
var reg_quot        = /\\"/g,
    reg_space       = / /;


// z.B. führende Nullen ergänzen
function padding(zahl, character, length) {
    var string = "" + zahl;
    while (string.length < length) {
        string = character + string;
    }
    return string;
}


// baue eine Zeichenkette zusammen indem Vorkommen von %s durch weitere Argumente ersetzt werden
var reg_prozentNewLine = /%n/g,
    reg_percent        = /%/,
    reg_parameter      = /((\d+\$|<\$)?(\d+)?s)/;
function format(string) {
    var blocks = string.replace(reg_prozentNewLine, "\n").split(reg_percent);
    var result = blocks[0];
    var index = 1;
    for (var i = 1; i < blocks.length; i++) {
        if (blocks[i].length === 0) {
            // doppeltes Prozent-Zeichen hebt sich weg
            result += "%" + blocks[++i];
            continue;
        }
        if (blocks[i].search(reg_parameter) !== 0) {
            return "INVALID FORMAT SPECIFIED: '%" + blocks[i] + "'";
        }
        // wählt ein bestimmtes Argument (mehrfach) aus
        var dollar = RegExp.$2;
        // Breite, auf die das Argument mindestens gebracht werden soll durch voranstellen von Nullen/Leerzeichen
        var width = RegExp.$3;
        result +=
            padding(
                arguments[dollar ? dollar == "<$" ? index - 1 : parseInt(dollar.substr(0, dollar.length - 1)) : index++],
                width && width[0] == "0" ? "0" : " ",
                width ? parseInt(width) : 0)
            + blocks[i].substr(RegExp.$1.length);
    }
    return result;
}
// console.log("formatTest: '" + format("%smy%%string %1$s %02s%nx%3sx %s", "a", "b", "c") + "'");


var reg_date = /(.*?)((\d{1,2})\.(\d{1,2})\.(\d{2,4})[ _um]+(\d{1,2}:\d{2}).*?)/;
function parseDate(text) {
    if (text && text.search(reg_date) >= 0) {
        // *NICHT* via format(...) -> weitere reguläre Teilausdrücke für den Aufrufer erhalten!
        return new Date(RegExp.$4 + "/" + RegExp.$3 + "/"
            + (RegExp.$5.length != 2 ? "" : RegExp.$5[0] > "7" ? "19" : "20")
            + RegExp.$5 + " " + RegExp.$6);
    }
    return null;
}


function formatDate(date) {
    return format("%02s.%02s.%04s %02s:%02s",
        date.getDate(),
        date.getMonth() + 1,
        date.getFullYear(),
        date.getHours(),
        date.getMinutes());
}


// http://de.selfhtml.org/xml/darstellung/xpathsyntax.htm
function viaXpath(path, node, ordered) {
    return document.evaluate(path, node ? node : document, null,
        ordered ? XPathResult.ORDERED_NODE_SNAPSHOT_TYPE : XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
        null);
}

// https://developer.mozilla.org/en-US/docs/Introduction_to_using_XPath_in_JavaScript#First_Node
function viaXpath0(path, node, ordered) {
    return document.evaluate(path, node ? node : document, null,
        ordered ? XPathResult.FIRST_ORDERED_NODE_TYPE : XPathResult.ANY_UNORDERED_NODE_TYPE,
        null).singleNodeValue;
}


// Füge Behandlung von Umlauten zu Original-doHighlight(...) hinzu (darf kein '' enthalten!)
function doHighlightReplace(bodyText, searchTerm, highlightStartTag, highlightEndTag) {
    /* ACHTUNG: Diese Funktion wird serialisiert -> keine Zeilenende-Kommentare, nur "" für Zeichenketten!
     * Es wird in die Seite injiziert -> kein Zugriff auf den GreaseMonkey-Kontext hier im Skript!
     */
    if (!searchTerm) {
        /* verlasse die Methode bevor es zur Endlosschleife kommt! */
        return bodyText;
    }

    /* verfremdete Umlaute aus dem Suchbegriff in der URL wiederherstellen
     * umständen    umst%C3%A4nden
     * zerstörung   zerst%C3%B6rung
     * schlüssel    schl%C3%BCssel
     * schließen    schlie%C3%9Fen
     */
    searchTerm = searchTerm
        .replace(/\u00C3\u00A4/g, "ä")
        .replace(/\u00C3\u00B6/g, "ö")
        .replace(/\u00C3\u00BC/g, "ü")
        .replace(/\u00C3\u0178/g, "ß")
        ;

    /* Ausgabe der übergebenen Parameter */
    /* bodyText += "<BR>-" + searchTerm + "-"; */

    return window.doHighlightOriginal(bodyText, searchTerm, highlightStartTag, highlightEndTag);
}


var umlautHighLight = document.createElement("SCRIPT");
umlautHighLight.type = "text/javascript";
umlautHighLight.innerHTML = 'window.doHighlightOriginal = window.doHighlight; window.doHighlight = ' + doHighlightReplace + ';';
document.head.appendChild(umlautHighLight);


// *****************************************
// ***** BEGINN: Globale Konfiguration *****
// *****************************************

// Verzögerung in Sekunden zwischen dem Öffnen eines Themas und es als gelesen markieren.
var lese_verzoegerung = await GM.getValue("lese_verzoegerung");
if (!lese_verzoegerung) {
    lese_verzoegerung = 1 * 60;
    GM.setValue("lese_verzoegerung", lese_verzoegerung);
}

// Minuten, die für nachträgliche Änderungen an einem Beitrag verstreichen dürfen, bevor er gelb markiert wird
var aenderungen_markieren = await GM.getValue("aenderungen_markieren");
if (!aenderungen_markieren) {
    aenderungen_markieren = 10;
    GM.setValue("aenderungen_markieren", aenderungen_markieren);
}

var beitraege_pro_seite = await GM.getValue("beitraege_pro_seite");
if (!beitraege_pro_seite) {
    beitraege_pro_seite = 20;
    GM.setValue("beitraege_pro_seite", beitraege_pro_seite);
}

var font_size_offset = await GM.getValue("font_size_offset", 0);
if (window.location.search.search(/font-size=(-?[0-9]+)/) != -1) {
    font_size_offset = parseInt(RegExp.$1);
    GM.setValue("font_size_offset", font_size_offset);
}

var debug = await GM.getValue("debug");

// aktualisieren, wenn die entsprechende Seite der Einstellungen im Forum geöffnet wird
if (window.location.search == "?action=mysite&mysite=settings&forumid=5") {
    // Beim Ansehen der Einstellungen den aktuellen Wert merken (beim Ändern wird hinterher diese Seite neu geladen)
    var val = viaXpath0("//INPUT[@name='setbpt' and @value]").value;
    if (parseInt(val) != beitraege_pro_seite) {
        alert(format("Abweichende Anzahl Beiträge pro Seite %s wird ignoriert! Verwende Standard %s",
            val, beitraege_pro_seite));
        // GM.setValue("beitraege_pro_seite", beitraege_pro_seite = parseInt(val));
    }
}

// ***************************************
// ***** ENDE: Globale Konfiguration *****
// ***************************************


// Behandlung der Schriftgröße
// ACHTUNG: Keine Zahlenwerte für die Schriftgröße verwenden!
// ACHTUNG: Schriftgröße einstellen, bevor Thema zu Beitrag scrollt -> Verschiebung bei Größenänderung der Elemente
function applyFontSize(root) {
    var fonts = viaXpath(".//FONT[@size]", root);
    for (i = 0; i < fonts.snapshotLength; i++) {
        var f = fonts.snapshotItem(i);
        f.size = parseInt(f.size) + font_size_offset;
    }
}
applyFontSize(document.body);

// biete Buttons für die Schriftgröße an
var config = viaXpath0(
    "//IMG[contains(@src,'/images/theme1/home.gif')]//ancestor::TD/A[last()]");
if (config) {
    var control = document.createElement("FONT");
    control.size = 3 + font_size_offset;
    control.innerHTML =
        "<A href='?font-size=" + (font_size_offset + 1) + "' title='Schrift vergrößern' >+A</A> / " +
        "<A href='?font-size=" + (font_size_offset - 1) + "' title='Schrift verkleinern'>a-</A>";
    config.parentNode.insertBefore(control, config.nextSibling);
}


// Sprung in die Privaten Nachrichten relativieren
config = viaXpath0("//FONT/FONT//A[contains(text(), 'neue Nachricht') and contains(@href, '&mysite=pm')]");
if (config) {
    config.href = config.href.replace(/.*(\/)/, "$1");
}


// Umlaut-Fehler korrigieren
var option = viaXpath("//SELECT/OPTION");
var reg_ae = /\u00C3\u00A4/g,
    reg_oe = /\u00C3\u00B6/g,
    reg_ue = /\u00C3\u00BC/g,
    reg_sz = /\u00C3\u0178/g;
for (i = 0; i < option.snapshotLength; i++) {
    var o = option.snapshotItem(i);
    o.innerHTML = o.innerHTML
        .replace(reg_ae, "ä")
        .replace(reg_oe, "ö")
        .replace(reg_ue, "ü")
        .replace(reg_sz, "ß")
        ;
}


// Ergänze den Wochentag zum Datum
const wochentag = [" Sonntag ", " Montag ", " Dienstag ", " Mittwoch ", " Donnerstag ", " Freitag ", " Samstag "];
function showDayOfWeek() {
    var datum = document.getElementsByTagName("B");
    for (var i = 0; i < datum.length; i++) {
        if (datum[i].innerHTML == "Datum:") {
            var t = datum[i].nextSibling;
            var date = parseDate(t.data);
            if (date) {
                t.data = wochentag[date.getDay()] + formatDate(date);
            }
        }
    }
}


const millisDay = 86400000;
function formatDateDifference(toShow, reference) {
    var difference = new Date(Math.abs(reference - toShow));
    var tage = Math.floor(difference.getTime() / millisDay);
    return format("%s%s%n%s%s%s:%02s",
        wochentag[toShow.getDay()],
        formatDate(toShow),
        toShow < reference ? "+" : "-",
        tage === 0 ? "" : tage + " Tage ",
        difference.getUTCHours(),
        difference.getUTCMinutes());
}


// In den privaten Nachrichten die Wochentage einblenden.
if (window.location.search.search(/mysite=(mysite|pm|delentry)/i) > 0) {
    document.title = "KG-Postfach";
    showDayOfWeek();
    logTime();
    return;
}


// alle Attribute und Funktionen eines Objektes ausgeben
function analyzeObjectNode(node) {
    if (!node) {
        return "" + node;
    }
    function Prop(name, value) {
        this.name = name;
        this.value = value;

        this.toString = function() {
            return this.name + (this.name.length < 8 ? "\t" : "") + "\t= " + this.value;
        }
    }
    function sort(a, b) {
        a = a.name.toLowerCase();
        b = b.name.toLowerCase();
        if (a < b) { return -1; }
        if (b < a) { return  1; }
        return 0;
    }

    var attrs = new Array();
    var nulls = new Array();
    var funcs = new Array();
    var nativ = new Array();
    for (var property in node) {
        var str = node[property];
        if (str == null) {
            nulls.push(new Prop(property, str));
        } else {
            str = "" + str;
            if (0 == str.search("function ")) {
                if (0 < str.search("[native code]")) {
                    nativ.push(new Prop(str.replace(/\s+(\[native code\])\s+/, "\t$1 "), ""));
                } else {
                    funcs.push(new Prop(str, "// " + property + " ENDE"));
                }
            } else {
                attrs.push(new Prop(property, str));
            }
        }
    }

    var result = node + "\n\n";
    result += attrs.length == 0 ? "" : "===== Attribute: =====\n\n";
    attrs.sort(sort);
    result += attrs.join("\n");
    result += nulls.length == 0 ? "" : "\n\n===== Attribute ohne Wert: =====\n\n";
    nulls.sort(sort);
    result += nulls.join("\n");
    result += funcs.length == 0 ? "" : "\n\n===== Funktionen: =====\n\n";
    funcs.sort(sort);
    result += funcs.join("\n\n");
    result += nativ.length == 0 ? "" : "\n\n===== native Funktionen: =====\n\n";
    nativ.sort(sort);
    result += nativ.join("\n");

    return result;
}


function pimpEditor() {
    // https://developer.mozilla.org/de/docs/Web/API/HTMLTextAreaElement
    function insertMetachars(sStartTag, sEndTag) {
        var bDouble = arguments.length > 1,
            oMsgInput = document.creator.message,
            nSelStart = oMsgInput.selectionStart,
            nSelEnd = oMsgInput.selectionEnd,
            sOldText = oMsgInput.value;
        oMsgInput.value = sOldText.substring(0, nSelStart)
            + (bDouble ? sStartTag + sOldText.substring(nSelStart, nSelEnd) + sEndTag : sStartTag)
            + sOldText.substring(nSelEnd);
        oMsgInput.setSelectionRange(
            bDouble || nSelStart === nSelEnd ? nSelStart + sStartTag.length : nSelStart,
            (bDouble ? nSelEnd : nSelStart) + sStartTag.length);
        oMsgInput.focus();
    }

    function insertQuestion(frage, vorgabe, sStartTag, sEndTag) {
        if (sStartTag.indexOf("[") < 0 && sStartTag.indexOf("<") < 0) {
            sStartTag = "[" + sStartTag + "]";
        }
        if (!sEndTag) {
            sEndTag = sStartTag.replace(/([\[<])/, "$1/");
        }
        var param = frage ? prompt(frage, vorgabe) : vorgabe;
        if (null == param) {
            // Abbrechen gedrückt
            return;
        } else if (0 < param.length) {
            sStartTag = sStartTag.replace(/([\]>])/, "=" + param + "$1")
        }
        insertMetachars(sStartTag, sEndTag);
    }

    function insertClear() {
        var oMsgInput = document.creator.message,
            nSelStart = oMsgInput.selectionStart,
            nSelEnd = oMsgInput.selectionEnd,
            sOldText = oMsgInput.value;
        var prefix    = sOldText.substring(0, nSelStart),
            selection = sOldText.substring(nSelStart, nSelEnd),
            suffix    = sOldText.substring(nSelEnd);
        if (nSelStart === nSelEnd) {
            prefix = prefix.replace(/\[[^\]]*\]/g, "").replace(/<[^>]*>/g, "");
            suffix = suffix.replace(/\[[^\]]*\]/g, "").replace(/<[^>]*>/g, "");
        } else {
            selection = selection.replace(/\[[^\]]*\]/g, "").replace(/<[^>]*>/g, "");
        }
        oMsgInput.value = prefix + selection + suffix;
        oMsgInput.setSelectionRange(prefix.length, prefix.length + selection.length);
        oMsgInput.focus();
    }

    function insertTable() {
        var oMsgInput = document.creator.message,
            nSelStart = oMsgInput.selectionStart,
            nSelEnd = oMsgInput.selectionEnd,
            sOldText = oMsgInput.value,
            structure;
        var selection = "";
        if (nSelStart === nSelEnd) {
            do {
                selection = prompt("Wie viele Zeilen / Spalten soll die Tabelle haben?", "3/3");
                if (null == selection) {
                    return;
                }
            } while (!selection.match(/^ *(\d+) *\/ *(\d+) *$/))
            structure = [];
            var line = "";
            for (var j = 0; j < parseInt(RegExp.$2); j++) {
                line += " ";
            }
            for (var i = 0; i < parseInt(RegExp.$1); i++) {
                structure.push(line);
            }
        } else {
            structure = sOldText.substring(nSelStart, nSelEnd).split("\n");
        }
        selection = "";
        for (var line of structure) {
            selection += "[tr]";
            for (var cell of line.split(" ")) {
                selection += "[td]" + cell + " [/td]";
            }
            selection = selection.replace(/(\[\/td\])$/, "\n$1") + "[/tr]";
        }
        selection = "\n[table]   " + selection + "[/table]\n";
        oMsgInput.value = sOldText.substring(0, nSelStart) + selection + sOldText.substring(nSelEnd);
        oMsgInput.setSelectionRange(nSelStart, nSelStart + selection.length);
        oMsgInput.focus();
    }

    // absolute Skript-URL ohne https in relative umwandeln -> gleicher Server wird nicht blockiert
    var cb1 = viaXpath0("//FORM//SCRIPT[contains(@src, '/images/theme1/codebuttons.js')]");
    if (!cb1) {
        return;
    }

    var cb2 = document.createElement("SCRIPT");
    cb2.type = "text/javascript";
//  cb2.src = "/images/theme1/codebuttons.js";
    cb2.innerHTML = "<!--\n"
        + insertMetachars + "\n"
        + insertQuestion + "\n"
        + insertClear + "\n"
        + insertTable + "\n-->";
    cb1.parentNode.insertBefore(cb2, cb1);
    cb1.parentNode.removeChild(cb1);

    // BBC-Syntax-Hilfe: https://www.kgforum.org/?action=help&forumid=5
    for (var textAttr of ["color", "size", "font"]) {
        cb1 = viaXpath0("//FORM//SELECT[@name='" + textAttr + "' and @onchange]");
        cb1.setAttribute("onchange",
            cb1.getAttribute("onchange").replace(
                new RegExp("show" + textAttr + "(.+)"),
                "insertQuestion(null, $1, '" + textAttr + "')"));
    }

    viaXpath0("//FORM//A[@href='javascript:bold()']"     ).href = "javascript:insertMetachars('[b]','[/b]')";
    viaXpath0("//FORM//A[@href='javascript:italicize()']").href = "javascript:insertMetachars('[i]','[/i]')";
    viaXpath0("//FORM//A[@href='javascript:underline()']").href = "javascript:insertMetachars('[u]','[/u]')";
    viaXpath0("//FORM//A[@href='javascript:center()']"   ).href = "javascript:insertMetachars('\\n[center]\\n','\\n[/center]\\n')";
    viaXpath0("//FORM//A[@href='javascript:hyperlink()']").href = "javascript:insertQuestion('Web-Adresse (leer fügt nur Tags ein)', 'http://www.','url')";
    viaXpath0("//FORM//A[@href='javascript:meiler()']"   ).href = "javascript:insertQuestion('Email-Adresse (leer fügt nur Tags ein)', '@','email')";
    viaXpath0("//FORM//A[@href='javascript:image()']"    ).href = "javascript:insertMetachars('\\n[img]','[/img]\\n')";
    viaXpath0("//FORM//A[@href='javascript:showcode()']" ).href = "javascript:insertMetachars('\\n[code]\\n','\\n[/code]\\n')";
    viaXpath0("//FORM//A[@href='javascript:quote()']"    ).href = "javascript:insertMetachars('\\n[quote]\\n','\\n[/quote]\\n')";
    viaXpath0("//FORM//A[@href='javascript:move()']"     ).href = "javascript:insertMetachars('\\n[move]','[/move]\\n')";

    cb1 = viaXpath0("//FORM//A[@href='javascript:list()']");
    cb1.href = "javascript:insertQuestion('Aufzählung: leer=bullet 1=1,2,3 A=A,B,C a=a,b,c I=I,II,III i=i,ii,iii', '', '\\n[list]')";
    cb2 = cb1.cloneNode();
    cb2.innerHTML = "[*]"
    cb2.title = "Aufzählungs-Punkt einfügen";
    cb2.href = "javascript:insertMetachars('\\n[*]', '')";
    cb1.parentNode.insertBefore(cb2, cb1.nextSibling);

    cb1 = viaXpath0("//FORM//A[@href='javascript:shadow()']")
    cb1.href = "javascript:insertMetachars('<font style=\u0022text-decoration:line-through\u0022>','</font>')";
    cb1.title = "Durchgestrichen";

    cb1 = viaXpath0("//FORM//A[@href='javascript:glow()']");
    cb1.href = "javascript:insertMetachars('\\n-----\\n')";
    cb1.title = "Gedankenstrich";

    cb1 = viaXpath0("//FORM//A[@href='javascript:flash()']");
    cb1.href = "javascript:insertClear()";
    cb1.title = "Formatierung zurücksetzen";

    cb2 = cb1.cloneNode();
    cb2.innerHTML = "[table]";
    cb2.title = "Tabelle einfügen";
    cb2.href = "javascript:insertTable()";
    cb1.parentNode.insertBefore(cb2, cb1.nextSibling);
    cb1.parentNode.insertBefore(document.createTextNode(" "), cb2);

    cb1 = viaXpath("//FORM//MAP//AREA[@href and @onclick]");
    var reg_smilie = /document.+\+=(.+);/g;
    for (var i = 0; i < cb1.snapshotLength; i++) {
        cb2 = cb1.snapshotItem(i);
        cb2.setAttribute("onclick", "javascript:" + cb2.getAttribute("onclick").replace(reg_smilie, "insertMetachars($1);"));
    }
}


// ***************************************************
// ***** BEGINN: Verwalte Lesezeichen für Themen *****
// ***************************************************

const bits = 8;
const mask = (1 << bits) - 1;
// Zeitstempel, wann der Cache zuletzt geschrieben wurde (um festzustellen, ob meine Kopie veraltet ist)
var known_age = null;

async function updateNeeded() {
    var age = parseInt(await GM.getValue("zuletzt_geschrieben"));
    if (!age) {
        // Falle zurück auf das aktuelle Datum, wenn bislang keines gesetzt war
        known_age = new Date().getTime();
        GM.setValue("zuletzt_geschrieben", known_age.toString());
    } else if (!known_age) {
        known_age = age;
    } else if (known_age < age) {
        known_age = age;
        return true;
    }
    return false;
}

function Entry(threadId, maxCount, postId, lastDate) {
    this.threadId = threadId;
    this.maxCount = maxCount ? maxCount : 0;
    this.postId   = postId   ? postId   : 0;
    this.lastDate = lastDate ? lastDate : 0;

    const base = 36;

    this.encode = function() {
        return Math.floor(this.threadId >> bits).toString(base)
            + " " + this.maxCount.toString(base)
            + " " + this.postId.toString(base)
            + " " + Math.floor(this.lastDate ? this.lastDate / 60000 : 0).toString(base);
    };

    this.decode = function(lo, line) {
        line = line.split(reg_space);
        this.threadId = (parseInt(line[0], base) << bits) + lo;
        this.maxCount =  parseInt(line[1], base);
        this.postId   =  parseInt(line[2], base);
        this.lastDate =  parseInt(line[3], base) * 60000;
        return this;
    };

    this.toString = function() {
        return "threadId=" + this.threadId
            + " maxCount=" + this.maxCount
            + " postId="   + this.postId
            + " lastDate=" + new Date(this.lastDate);
    };
}

function key(threadId) {
    return "zzz_" + padding((threadId & mask).toString(16), 0, 2);
}

var reg_comma = /,/;
async function readCache(threadId) {
    var cache = [];
    var toParse = await GM.getValue(key(threadId));
    if (toParse) {
        toParse = toParse.split(reg_comma);
        var lo = threadId & mask;
        for (var i = 0; i < toParse.length - 1; i++) {
            var entry = new Entry().decode(lo, toParse[i]);
            // Argh - assoziative Arrays wandeln Zeichenketten mit Ziffern wieder zurück in Zahlen
            // -> Länge z.B. 90.000 für drei Einträge
            cache["t" + entry.threadId] = entry;
        }
    }
    return cache;
}

async function writeCache(threadId, cache) {
    var toSave = "";
    for (var tid in cache) {
        var entry = cache[tid];
        if (entry && entry.maxCount > 0) {
            toSave += entry.encode() + ",";
        }
    }
    if (await updateNeeded()) {
        console.log("Concurrent modification on cache for " + threadId);
    } else {
        known_age = new Date().getTime();
        GM.setValue("zuletzt_geschrieben", known_age.toString());
        GM.setValue(key(threadId), toSave);
    }
}

// Ein Cache für die eingelesenen Informationen über früher geöffnete Themen (kann Promisses enthalten)
var known_threads = [];

async function getCache(threadId) {
    var lo = threadId & mask;
    var cache = null;
    if (await updateNeeded()) {
        // verwerfe veralteten Cache
//      delete known_threads;
        known_threads = [];
    } else {
        cache = known_threads[lo];
    }
    if (!cache) {
        // Kein Treffer, es wird neu eingelesen.
        cache = known_threads[lo] = readCache(threadId);
    }
    return cache;
}

async function getThreadEntry(threadId) {
    var result = (await getCache(threadId))["t" + threadId];
    // inlining result causes strange warning on loading a board-overview
    // reference to undefined property getCache(...)[("t" + threadId)]
    return result;
}

async function getOrCreateThreadEntry(threadId) {
    var cache = await getCache(threadId);
    var key = "t" + threadId;
    var entry = cache[key];
    return  entry ? entry : cache[key] = new Entry(threadId);
}

async function saveThreadEntry(entry) {
    if (entry.threadId) {
        // Nur schreiben, falls danach
        var cache = await getCache(entry.threadId);
        var key = "t" + entry.threadId;
        if (cache[key] != entry) {
            delete cache[key];
            cache[key] = entry;
        }
        writeCache(entry.threadId, cache);
    }
}

var deleteParameter = null;
var safeTimer = null;

async function deleteEntry() {
    var cache;
    if (deleteParameter && (cache = await getCache(deleteParameter))) {
        var key = "t" + deleteParameter;
        var entry = cache[key];
        if (entry && window.confirm("Lesezeichen für diesen Thema wirklich löschen?\n" + entry)) {
            if (safeTimer) {
                window.clearTimeout(safeTimer);
                safeTimer = null;
            }
            cache[key] = null;
            // delete entry;
            writeCache(deleteParameter, cache);
        }
    }
}

// *************************************************
// ***** ENDE: Verwalte Lesezeichen für Themen *****
// *************************************************


/**
 * @param string1 the first string to compare (null is identified with the empfy string)
 * @param string2 the second string to compare
 * @param maxDistance shortcut calculation, if this maximum as exceeded early
 * @return 0 if the Strings are identical
 *      a negative value if the shorter string is a prefix or suffix of the longer string
 *      a positive value indicates the minimum number of atomic edit operations to transform string1 into string2
 *          +1 to delete a range of characters from string1
 *          +1 to insert a single character from string2
 *          +1 to substitute a single character not matched between string1 and string2
 * @see http://rosettacode.org/wiki/Levenshtein_distance#JavaScript
 */
var prefix = 0, suffix = 0;
function editDistance(string1, string2, maxDistance) {
    // delete = remove characters from string1 to match string2
    // insert = add characters to string2 not present in string1
    if (!maxDistance || maxDistance <= 0) {
        maxDistance = 1e9 + 1e6 + 1e3 + 1e0;
    }

    // exclude common prefix p / suffix s as they won't change the result: ed(px,py) = ed(x,y) = ed(xs,ys)
    // checked in linear TIME
    prefix = 0;
    suffix = 0;
    var min = string1 && string2 ? Math.min(string1.length, string2.length) : 0;
    var max = min > 0 ? Math.max(string1.length, string2.length) : string1 ? string1.length : string2 ? string2.length : 0;
    while (prefix < min && string1[prefix] == string2[prefix]) {
        prefix++;
    }
    if (prefix == min) {
        // matched a true prefix or the strings equal
        return prefix - max;
    }

    while (suffix < min && string1[string1.length - 1 - suffix] == string2[string2.length - 1 - suffix]) {
        suffix++;
    }
    if (suffix == min) {
        // matched a true suffix
        return suffix - max;
    }
    // now the strings cannot be equal anymore

    if (maxDistance <= string2.length - string1.length) {
        // correctly stop on: maxDistance <= max - min
        // overwhelmed by required number of insertions without even trying to match anything
        return maxDistance;
    }

    if (min - prefix - suffix <= 0) {
        // prefix and suffix overlap -> the correct result is the difference in length: max - min
        return string1.length > min ? 1 : max - min; // reduce penalty for streamDelete to 1
    }
    var length = string1.length - prefix - suffix;
    var other = string2.length - prefix - suffix;

    // need only two rows of the matrix at any time (use modulo operator % 2 to access even and odd lines)
    // linear SPACE
    var twoRows = [];
    twoRows[0] = [];
    twoRows[1] = [];

    // initialize first row (independed of strings as it represents transforming into the empty sequence)
    for (var i = 0; i <= length; i++) {
        // horizontal step with a penalty of constant 0 to delete string1.substr(prefix, i) into the empty sequence
        twoRows[0][i] = 0; // normally would be i
    }

    // TIME O(n*m)
    for (var j = 1; j <= other; j++) {
        // each iteration calculates the cost of changing string1.substr(prefix, i) into string2.substr(prefix, j)

        // initialize first column (same as first row)
        // vertical step with a penalty of j - (j - 1) = 1 to insert a substring(prefix, j) into the empty sequence
        twoRows[j%2][0] = j;
        var earlyExit = true, streamDelete = false;

        for (i = 1; i <= length; i++) {
            // compare the characters at i and j respectively (penalty 1 for substitution if not matched)
            var sub = (string1[prefix + i - 1] == string2[prefix + j - 1] ? 0 : 1) + twoRows[(j + 1)%2][i - 1];
            var ins = 1 + twoRows[(j + 1)%2][i];
            // penalty for n consecutive deletions is constant 1 (remove whole ranges from string1 at cost 1)
            var del = (streamDelete ? 0 : 1) + twoRows[j%2][i - 1];
            twoRows[j%2][i] = Math.min(sub, ins, del);
            streamDelete = twoRows[j%2][i] == del;
            if (twoRows[j%2][i] <= maxDistance) {
                earlyExit = false;
            }
        }
//         console.log("p=" + prefix + " s=" + suffix + " s1='" + string1 + "' s2='" + string2 + "' tr=" + twoRows[j%2]);

        if (earlyExit) {
            // entries in the matrix can only move or grow but they won't shrink again
            // it can only get worse from here
            return maxDistance;
        }
    }
    return twoRows[other%2][length];
}
// var x1 = 'Joooo, die Arch Fraktion wird hier immer größer. Aber Dich kann keiner mehr enholen, wo Du doch damit angefangen hasti=cwm11.gif';
// var x2 = 'Joooo, die Arch Fraktion wird hier immer größer. Aber Dich kann keiner mehr einholen, wo Du doch damit angefangen hasti=cwm11.gif';
// console.log("test editDistance(\n'" + x1 + "',\n'" + x2 + "',10)\n=" + editDistance(x1, x2, 10)
//     + " unlimited=" + editDistance(x1,x2) + " p=" + prefix + " s=" + suffix);


var lightCyan   = "#8ee", // Alles OK, bereits gelesen, nichts Neues
    lightGreen  = "#5f7", // Neuigkeiten
    lightYellow = "#cc7", // Warnung
    lightRed    = "#e88"; // Fehler


// Vergleiche den Text direkt innerhalb der beiden Tabellen-Zellen von Zitat und je einem Original-Beitrag
var reg_ellipsis    = /(\s*\[?\.{3,}\]?\s*)/g,
    reg_imageName   = /^.*\//,
    reg_multiSpace  = /\s{2,}/g,
    reg_multiZeilen = /\s*?\n\s*/g,
    reg_newLine     = /\n/,
    reg_singlequot  = /[´`]/g;
// Sprungmarken für alle Zitate durchnummerieren
var hit = 0, partial = 0, miss = 0;
function diffTextLines(beitragNr, zitatNr, node, original, notFound, endOfInput)  {
    var logOutput = debug && beitragNr == 2 && zitatNr == 1; // beitragNr < zitatNr
    var result = "", i, j, unmatched;

    // erfasse den Text eines Knoten
    if (notFound || !node.diffTextLines) {
        for (i = 0; i < node.childNodes.length; i++) {
            var n = node.childNodes[i];
            if (logOutput) {
                console.log(format("(b=%s z=%s) Erfasse Kinder von %s (%s<%s): %s '%s'", beitragNr, zitatNr,
                    node.nodeName, i, node.childNodes.length, n.nodeName, (n.nodeName == "#text" ? n.data : "")));
            }
            if (n.nodeName == "#text") {
                result += " " + n.data
                    .replace(reg_multiSpace, " ")
                    .replace(reg_quot, '"')
                    .replace(reg_singlequot, "'");  // Akzente in einfaches Hochkomma umwandeln
            } else if (n.nodeName == "BR") {
                result += "\n";
            } else if (n.nodeName == "IMG") {
                // Schneide die URL weg, in der Hoffnung, dass der Dateiname eindeutig genug ist.
                // lange URL eines Smiley löst sonst falsch-positive partielle Treffer aus
                result += "I=" + n.src.replace(reg_imageName, "");
            } else if (n.nodeName == "A") {
                result += format(" A={%s,%s,%s}", n.name, n.href,
                    diffTextLines(beitragNr, zitatNr, n, null, notFound, endOfInput && i == node.childNodes.length - 1).replace(reg_ellipsis, ".."));
            } else if ("B,I,U,FONT".indexOf(n.nodeName) != -1) {
                result += " " + diffTextLines(beitragNr, zitatNr, n, null, notFound, endOfInput && i == node.childNodes.length - 1);
            } else if (n.nodeName == "HR" && n.color == "ffffff") {
                // nicht die Signatur vergleichen
                break;
            } else {
                // kurze Schreibweise: keine falsch Positiven bei kurzen Chat-Telegrammen mit eingebettetem Zitat
                result += format("%n=%s=%n",
                    n.nodeName == "BLOCKQUOTE" && n.firstChild.nodeName == "TABLE"
                        ? viaXpath0(".//U[@id]", n).id
                        : n.nodeName);
            }
            if (notFound) {
                if (logOutput) {
                    console.log(format("suche nf[0]='%s'%nvon %s in result='%s'", notFound[0], notFound.length, result));
                }
                unmatched = "";
                var warning = true;
                while (notFound.length > 0 // sind noch Fehler zuzuordnen
                    // UND ( wurde die Quell-Zeile bereits gelesen ODER ist der Text komplett )
                    && ( (j = result.indexOf(notFound[0])) != -1 || endOfInput && i == node.childNodes.length - 1 )) {
                    unmatched += notFound[0] + " ";
                    if (notFound[0].length > 1 && !notFound[0].match(reg_ellipsis)) {
                        // mehr als ein Buchstabe und keine Auslassungszeichen -> dicker Hund wird rot unterlegt!
                        warning = false;
                    }
                    if (logOutput) {
                        console.log(format(
                            "Markiere nicht gefunden: nf[0]='%s'%n@index j=%s in result='%s'%nnode='%s'%nw=%s",
                            notFound[0], j, result,
                            n && n.nodeName == "#text" ? "'" + n.data + "'" : n,
                            warning));
                    }
                    // verhindern, dass das gleiche Wort noch einmal gefunden wird. (notFound ist sortiert)
                    result = result.substr(0, j) + result.substr(j + notFound[0].length);
                    notFound.shift();
                }
                if (unmatched.length > 0) {
                    var nfMarker = document.createElement("SPAN");
                    node.insertBefore(nfMarker, n);
                    nfMarker.appendChild(n);
                    unmatched = unmatched.trim();
                    result = result.trim();
                    nfMarker.title = "Unbekannter oder geänderter Text:\n'" + unmatched + "'";
                    nfMarker.style = "background-color:" + (warning ? lightYellow : lightRed);
                }
                if (notFound.length === 0) {
                    // alles markiert, nicht mehr weiter suchen
                    return "";
                }
            }
        }

        result = result
            .replace(reg_ellipsis, "\n$1\n")    // an Auslassungszeichen umbrechen
            .replace(reg_multiZeilen, "\n")     // Zeilenumbrüche zusammenfassen
            .replace(reg_multiSpace, " ")       // Weißraum zusammenfassen
            .trim();                            // Leerzeichen an Anfang und Ende entfernen
        // erfassten Text von Beiträgen temporär merken (in GM-Wrapper-Objekt)
        node.diffTextLines = result;
    } else {
        result = node.diffTextLines;
    }

    // Zuordnung von Absätzen zwischen Original und Zitat
    if (original) {
        // extrahiere den Text aus dem Original-Beitrag
        var org = diffTextLines(beitragNr, zitatNr, original);
        if (org !== "" && org.indexOf(result) == -1) { // überspringe komplett leere Beiträge
            // Suche teilweise Treffer aus ganzen Absätzen
            var count = 0, expected = result.length, outstanding = expected,
                expectedQuarter = Math.max(10, expected / 4);
            if (logOutput) {
                console.log(format("Overview: %s: org='%s'%n%s: result='%s'%no.indexOf(r)=%s u=%s p=%s s=%s e=%s", beitragNr,
                    org, zitatNr, result, org.indexOf(result), editDistance(org, result), prefix, suffix, expected));
            }
            var found = org.split(reg_newLine);
            found.removeFirst = function(value) {
                // suche und entferne erstes Vorkommen
                for (var i = 0; i < this.length; i++) {
                    if (this[i] == value) {
                        this.splice(i, 1);
                        break;
                    }
                }
            };
            outstanding -= found.length - 1; // minus die Anzahl der Zeilenumbrüche
            org = org.split(reg_newLine);
            result = result.split(reg_newLine);
            notFound = [];
            j = 0;
            NEXT_QUOTE:
            for (var aj = result.length; 0 < aj; j++, aj--) {
                // Abbruchbedingung schon in der Schleife prüfen -> frühzeitig aussteigen, wenn nicht mehr erfüllbar
                if (0 < notFound.length && aj < found.length && count + outstanding < expectedQuarter) {
                    // early miss
                    return null;
                }
                unmatched = result[j];
                outstanding -= unmatched.length;
                for (i = 0; i < org.length; i++) {
                    // Edit-Distanz kleiner 5% der Länge (1 Fehler toleriert je 20 gültige Zeichen)
                    var p5 = Math.max(3, Math.trunc(0.05 * Math.min(org[i].length, unmatched.length)));
                    var ed = editDistance(org[i], unmatched, p5);
                    if (logOutput) {
                        console.log(format("Detail: %s: i=%s<%s org='%s'%n"
                            + "%s: j=%s<%s result='%s'%n"
                            + "o.indexOf(r)=%s  p5=%s ed=%s u=%s p=%s s=%s nf=%s f=%s c=%s",
                            beitragNr, i, org.length, org[i],
                            zitatNr, j, result.length, result[j],
                            org[i].indexOf(result[j]), p5, ed, editDistance(org[i], result[j]), prefix, suffix,
                                notFound.length, found.length, count));
                    }
                    // echter Treffer
                    if (0 === ed
                        // ODER unscharfer Treffer (mindestens 5 Zeichen)
                        || 0 < ed && ed < p5 && 5 < unmatched.length
                        // ODER signifikantes Präfix/Suffix gefunden
                        || prefix + suffix > 35
                        // ODER unmatched als komplettes Präfix/Suffix in org enthalten (> Smiley a 11 Buchstaben)
                        || ed < 0 && 11 < unmatched.length && unmatched.length < org[i].length) {

                        found.removeFirst(org[i]);
                        // das restliche Zitat wurde vollständig gefunden, evtl. mit kleinen Änderungen
                        count += unmatched.length;
                        if (0 < ed && org[i].indexOf(result[j]) == -1) {

                            // markiere den unscharfen Treffer
                            unmatched = unmatched.substring(prefix, unmatched.length - suffix);
                            count -= ed < p5 ? ed : unmatched.length;
                            if (unmatched.length > 11) {
                                // entweder min. 2 echte Änderungen -> erster & letzter Buchstabe von unmatched
                                // oder Änderung = Löschen von zusätzlichem umschließenden Text in org
                                // suche das mittlere Drittel und expandiere die Ränder
                                var drittel = unmatched.length / 3 | 0;
                                var b = drittel, e = 2 * drittel;
                                var mitte = unmatched.substring(b, e);
                                var begin = org[i].indexOf(mitte, prefix);
                                if (begin != -1) {
                                    var end = begin + drittel;
                                    while (0 < begin && 0 < b && org[i][begin - 1] == unmatched[b - 1]) {
                                        begin--; b--;
                                    }
                                    while (end < org[i].length && e < unmatched.length && org[i][end] == unmatched[e]) {
                                        end++; e++;
                                    }
                                    if (logOutput) {
                                        console.log(format(
                                            "d=%s (%s,%s) (%s,%s)%norg(%s): '%s'%nunmatched(%s): '%s'%nmitte(%s): '%s'",
                                            drittel, begin, end, b, e,
                                            org[i]   .length, org[i].substr(begin, unmatched.length),
                                            unmatched.length, unmatched,
                                            mitte    .length, mitte));
                                    }
                                    unmatched = unmatched.substr(0, b)
                                        + (b === 0 || e == unmatched.length ? "" : "...")
                                        + unmatched.substr(e);
                                }
                            }

                            if (unmatched.length > 0) {
                                if (unmatched.length > 11) {
                                    // versuche den Rest noch gegen die anderen Absätze zu vergleichen
                                    continue;
                                }
                                // gib auf, einzelne Worte weiter zuzuordnen
                                break;
                            }
                        }
                        // unmatched wurde vollständig gefunden
                        continue NEXT_QUOTE;
                    } else if (!org[i].match(reg_ellipsis)) {
                        var k = unmatched.indexOf(org[i]);
                        // org ist in unmatched enthalten
                        if (k != -1) {
                            count += org[i].length;
                            // vermutlich wurde ein Zeilenumbruch gelöscht
                            unmatched = (unmatched.substr(0, k).trim()
                                + " " + unmatched.substr(k + org[i].length).trim())
                                .trim();
                            found.removeFirst(org[i]);
                            if (logOutput) {
                                console.log(format("%s: starte Suche neu für %s: '%s' wegen '%s'", i, j, unmatched, org[i]));
                            }
                            i = -1;
                        }
                    }
                }
                unmatched = unmatched.trim();
                if (unmatched.length > 0) {
                    notFound.push(unmatched);
                }
            }
            if (logOutput) {
                console.log(format("Result: nf=%s: '%s'%nf=%s: '%s'%nc=%s e=%s e/4=%s",
                    notFound.length, notFound, found.length, found, count, expected, expectedQuarter));
            }
            // alle Absätze Zitat ODER alle Absätze Beitrag und 10% Zeichen ODER 25% Zeichen zugeordnet
            if (notFound.length === 0 || found.length === 0 && count / expected > 0.1 || count > expectedQuarter) {
                // farbige Markierung für alle nicht passenden Textstellen (erneuter Durchlauf node mit notFound)
                var result = format("partial nf=%s f=%s c=%s e=%s r %s",
                    notFound.length, found.length, count, expected, (100 * count / expected).toFixed(2));
                diffTextLines(beitragNr, zitatNr, node, null, notFound, true);
                return result;
            }
            // miss
            return null;
        }
        // Zitat vollständig im Beitrag enthalten
        return "hit";
    }

    return result;
}


// KlammerGruppen: 1 type          forumid_2Boar_3Thre4_5MaxB6_7Offset
var reg_beitrag = /(display|print|reply)_5_(\d+)_(\d+)(_(\d+)(_(\d+))?)?.html/,
    reg_params  = /(display).*(threadid=(\d+))/,
    reg_relativeURL = /https?:\/\/[^\/\?]+|([\/\?])/,
    reg_absoluteURL = new RegExp("^https?://[^/?]*(" + domainList.join("|").replace(/\./g,"\\.") + ")([/?].+)$"),
    prefix = "contains(@href, '", suffix = "')", dclone = domainList.slice(0);
dclone.unshift("display");
var linkXPath = ".//A[" + prefix + dclone.join(suffix + " or " + prefix) + suffix + "]";
async function markThreadLinks(node) {
    // markiere innerhalb des Beitrages alle Links, die auf ein Lesezeichen gehen.
    var links = viaXpath(linkXPath, node);
    for (var j = 0; j < links.snapshotLength; j++) {
        var a = links.snapshotItem(j);
        var entry;
        if ((entry = reg_beitrag.exec(a.href)) || (entry = reg_params.exec(a.href))) {
            entry = await getThreadEntry(parseInt(entry[3]));
            if (entry) {
                a.style = "background-color:" + lightCyan;
                var stamp = new Date(entry.lastDate);
                a.title = format("Gelesen bis:%s%s\nBeiträge: %s",
                    wochentag[stamp.getDay()], formatDate(stamp), entry.maxCount);
            }
            // relativer Link -> nicht von preferredDomain weg navigieren (Cookie gilt nicht = Logout)
            a.href = a.href.replace(reg_relativeURL, "$1");
        } else if (entry = reg_absoluteURL.exec(a.href)) {
            a.href = entry[2];
        }
    }

}


// Übersicht aller Zitate, um Auswirkungen von Änderungen abschätzen zu können (vgl. Ist mit Soll)
var quoteOverview = null;
var reg_repairLink = /^https?:\/\/.*?\/((%5C|\\)(%22|"))?(https?:.*?)((%5C|\\)(%22|"))?$/i;
function highlightQuote(content, i) {
    // content[i].style = "border:5px solid red";

    markThreadLinks(content[i]);

    var quote = viaXpath(".//BLOCKQUOTE/TABLE/TBODY/TR/following-sibling::TR/TD", content[i]);
    if (debug && !quoteOverview && quote.snapshotLength > 0) {
        quoteOverview = document.createElement("TABLE");
        quoteOverview.border = 1;
        quoteOverview.innerHTML = "<TR><TH colspan=4>" + document.title + "<BR>"
            + window.location.hostname + window.location.pathname + "</TH></TR>"
            + "\n<TR><TH>Beitrag</TH><TH>Zitat</TH><TH>verweist auf</TH><TH>Typ</TH></TR>\n";
        document.body.appendChild(quoteOverview);
    }
    var innerQuote = 0;
    NEXT_QUOTE: // rückwärts: d.h. innere-geschachtelte = ältere *VOR* äußeren-umschließenden Zitaten
    for (var j = quote.snapshotLength - 1; 0 <= j; j--) {
        var q = quote.snapshotItem(j);
//         q.style = "border:10px solid red";
//         q.innerHTML = i + ":" + j;
//         continue;

        // repariere Links innerhalb des Zitates (überschüssige Anführungsstriche entfernen)
        var links = viaXpath(".//A[@href]", q);
        for (var k = 0; k < links.snapshotLength; k++) {
            var l = links.snapshotItem(k);
            // http://kgforum.org/%5C%22http://kgforum.org/display_5_2388_74514_562464.html#562464\%22
            l.href = l.href.replace(reg_repairLink, "$4");
            if (l.target) {
                l.target = l.target.replace(reg_quot, "");
            }
        }

        var zitat = viaXpath0("parent::TR/preceding-sibling::TR/TD/FONT", q), a;
        // suche rückwärts, weil das Original meist dicht dran ist
        for (k = i - 1; innerQuote <= k; k--) {
            var connection = diffTextLines(content[k].num, content[i].num, q, content[k]);
            if (connection) {
                a = document.createElement("A");
                a.name = connection[0] + (connection == "hit" ? hit++ : partial++);
                q.insertBefore(a, q.firstChild);
                q.setAttribute("bgcolor", connection == "hit" ? lightCyan : lightGreen);
                zitat.innerHTML += format(
                    " von <A href='#%s'><FONT color='white'><U id='BQ%s'>%<$s. %s am %s</U></FONT></A> (%s)",
                    content[k].ref, content[k].num, content[k].author, content[k].date,
                    connection == "hit"
                        ? "sicher"
                        : format("wahrscheinlich %5s %%", connection.substr(connection.lastIndexOf(" "))));
                if (debug) {
                    quoteOverview.innerHTML += format(
                        "<TR bgcolor='%s'>"
                            + "<TD><A href='#%s'>%s</A></TD>"   // Beitrag
                            + "<TD>" +          "%s"+ "</TD>"   // Zitat
                            + "<TD><A href='#%s'>%s</A></TD>"   // verweist auf
                            + "<TD><A href='#%s'>%s</A></TD>"   // Typ
                        + "</TR>%n",
                        q.getAttribute("bgcolor"),
                        content[i].ref,     content[i].num,     // Beitrag
                                            j,                  // Zitat
                        content[k].ref,     content[k].num,     // verweist auf
                        a.name,             connection);        // Typ
                }
                // Suche nicht nach früheren Beiträgen, die das innere Zitat nicht enthalten können
                innerQuote = 0 < j
                    && viaXpath0("ancestor::BLOCKQUOTE/parent::*", quote.snapshotItem(j - 1))
                    != viaXpath0("ancestor::BLOCKQUOTE/parent::*", q) ? k : 0;
                continue NEXT_QUOTE;
            }
        }
        // Keine Einschränkung für das nächste Zitat aus einem *nicht* gefundenen Zitat
        innerQuote = 0;

        // keiner der vorangegangenen Beiträge passt
        zitat.innerHTML += "<U id='BQ" + content[i].num + "'></U>";
        a = document.createElement("A");
        a.name = "m" + miss++;
        q.insertBefore(a, q.firstChild);
        q.setAttribute("bgcolor", lightYellow);
        if (i < 2 && i + 1 < content[i].num) {
            q.title = "Dieses Zitat kann evtl. in der Druckansicht zugeordnet werden,\n"
                + "weil dort alle Beiträge dieses Themas berücksichtigt werden!";
        }
        if (debug) {
            quoteOverview.innerHTML += format(
                "<TR bgcolor='%s'>"
                    + "<TD><A href='#%s'>%s</A></TD>"   // Beitrag
                    + "<TD>" +          "%s"+ "</TD>"   // Zitat
                    + "<TD>" +          "?" + "</TD>"   // verweist auf nichts
                    + "<TD><A href='#%s'>%s</A></TD>"   // Typ
                + "</TR>%n",
                q.getAttribute("bgcolor"),
                content[i].ref,     content[i].num,     // Beitrag
                                    j,                  // Zitat
                                                        // verweist auf nichts
                "m" + (miss - 1),   "miss");            // Typ
        }
    }
}


async function formatThreadHistory() {
    var marker = "geschrieben von: ";
    var rows = viaXpath(
        "//TABLE//TABLE//TABLE[@cellspacing=0 and @cellpadding=2]/TBODY/TR/TD/FONT[contains(text(), '" + marker + "')]");

    var offset = rows.snapshotLength;
    if (reg_beitrag.exec(window.location)) {
        var entry = await getThreadEntry(parseInt(RegExp.$3));
        if (entry) {
            offset = Math.max(offset, entry.maxCount);
        }
    }

    var content = [];
    // rückwärts um die ältesten Beiträge (möglicherweise mit der Vorlage eines Zitates zuerst zu verarbeiten)
    for (var i = 0, j = rows.snapshotLength - 1; 0 <= j; j--, i++) {
        var header = rows.snapshotItem(j);
        var current = viaXpath0(".//parent::TD/following-sibling::TD/FONT", header);
        var time = parseDate(current.innerHTML);
        current.innerHTML = RegExp.$1 + (time = wochentag[time.getDay()] + formatDate(time));
        current = viaXpath0(".//following::TD[@colspan=2]", current);
        current.author = header.innerHTML.substr(marker.length);
        current.date   = time;
        current.ref    = offset - j;
        current.num    = current.ref;
        header.innerHTML = "<a name=" + current.ref + ">" + current.ref + ": " + header.innerHTML + "</a>";
        content.push(current);
        highlightQuote(content, i);
    }
}


function loadPageContent(url) {
    if (debug) {
        document.getElementById("loadPNHistory").nextSibling.innerHTML += "\n" + url;
    }
    var childDoc = new XMLHttpRequest();
    childDoc.open("GET", url, false);
    childDoc.send();
    childDoc = childDoc.responseText;
    childDoc = childDoc.substring(childDoc.indexOf("<body"), childDoc.lastIndexOf("</body>")).replace(/^.*>/, "");

    // weil eine HTML-Seite und kein XML abgefragt wird, weise den Inhalt einem dummy-Knoten zu
    var childBody = document.createElement("DIV");
    childBody.innerHTML = childDoc;
    return childBody;
}


var reg_pnUserId = /&searchid2=(\d+)/;
function parseOnePageOfPN(a, incomming) {
    var c = incomming ? 0 : 2, m = c + 1;
    var last = "&start=";

    if (null == a.counter[m]) {
        last += 0;
    } else {
        if (a.counter[c] >= a.counter[m]) {
            return new Date();
        }
        last += a.counter[c];
    }

    var mode = incomming ? "&mode=old" : "&mode=send";
    //   https://www.kgforum.org/?action=mysite&mysite=pm&mode=old&forumid=5
    //   https://www.kgforum.org/?action=mysite&mysite=pm&mode=send&forumid=5
    var body = loadPageContent("/?action=mysite&mysite=pm" + mode + "&forumid=5" + last);

    last = parseInt(
        viaXpath0("//TABLE//TABLE//TABLE//TABLE//TABLE//TD"
            + (incomming ? "" : "[@colspan=2]") // überspringe die Postausgang-Text-Zelle mit gleichem Link
            + "/A[contains(@href, '" + mode + "')]",
        body
        ).innerHTML);
    if (null != a.counter[m] && a.counter[m] != last) {
        alert(format("Post-%sgang hat neues Maximum %s != %s!",
            (incomming ? "Ein" : "Aus"), a.counter[m], last));
    }
    a.counter[m] = last;

    var rows = viaXpath(".//TABLE//TABLE//TABLE[.//TR[1]//B[text()='Eintrag ']]/TBODY/TR", body);

    var targetTable = document.getElementById("PNHistory");
    if (!targetTable) {
        var header = rows.snapshotItem(0);
        targetTable = header.parentNode.parentNode.cloneNode();
        targetTable.innerHTML = "<TBODY id='PNHistory'></TBODY>";
        a.parentNode.insertBefore(targetTable, a);
        targetTable = targetTable.firstChild;
        targetTable.appendChild(header);
    }

    var stamp = new Date();
    for (var i = 1; i + 2 < rows.snapshotLength; i += 3) {
        var header = rows.snapshotItem(i    );
        var main   = rows.snapshotItem(i + 1);
        var footer = rows.snapshotItem(i + 2);

        var s = viaXpath0(".//FONT/B[text()='Datum:']", header).nextSibling;
        stamp = parseDate(s.data);
        a.counter[c]++;

        var user = viaXpath0(".//A[@href and IMG[contains(@src, '/images/theme1/message.gif')]]", footer);
        var author = viaXpath0("TD[@rowspan=3]/FONT/B", header);
        if (a.userId == parseInt(reg_pnUserId.exec(user.href)[1])
            || a.userId == null && 0 < a.userName.length && 0 <= author.innerHTML.toLowerCase().search(a.userName.toLowerCase())) {
            if (!incomming) {
                author.innerHTML = "von mir";
                author.parentNode.parentNode.removeChild(author.parentNode.parentNode.lastChild);
            }
            applyFontSize(header);
            applyFontSize(main);

            last = viaXpath0(".//FONT/B/A[@name]", header);
            header.id = last.name;
            last.innerHTML = format("%s %s%s - %s", last.name, incomming ? "Ein" : "Aus", a.counter[c], last.innerHTML);

            var td = viaXpath0("TD", main);
            td.ref = parseInt(header.id);
            td.author = author.innerHTML;
            td.date = s.data = wochentag[stamp.getDay()] + formatDate(stamp);

            // nicht hinten anhängen sondern an korrekter Position einsortieren!
            var j = a.content.length - 1;
            for (; 0 <= j; j--) {
                if (a.content[j].ref > td.ref) {
                    break;
                }
            }
            last = a.content[j + 1];
            if (last) {
                last = document.getElementById(last.ref);
            }
            targetTable.insertBefore(header, last);
            targetTable.insertBefore(main  , last);
            targetTable.insertBefore(footer, last);
            a.content.splice(j + 1, 0, td);
        }
    }

    if (debug) {
        for (var i = 0; i < a.content.length - 1; i++) {
            if (a.content[i].ref <= a.content[i + 1].ref) {
                alert("Sortierung verletzt!");
                window.location.hash = a.content[i].ref;
                break;
            }
        }
        a.nextSibling.innerHTML += format(" -> (%s-%s) %s", a.counter[m], a.counter[c], formatDate(stamp));
    }

    return stamp;
}


function loadPNHistory() {
    if (!debug) return; // Nicht versuchen, die Historie zu laden -> kaputt
    var a = document.getElementById("loadPNHistory");

    var instamp = parseOnePageOfPN(a, true);
    var i = 3; // lade maximal 3 Seiten auf einmal, bevor der User erneut klicken muss
    while ((a.counter[0] >= a.counter[1] || instamp < a.outstamp) && (null == a.counter[3] || a.counter[2] < a.counter[3]) && 0 < i) {
        a.outstamp = parseOnePageOfPN(a, false);
        i--;
    }
    if (debug) {
        if (!instamp || !a.outstamp) {
            alert("FAIL loadPNHistory: Datum nicht gesetzt!");
        }
        if (a.counter[1] == null || a.counter[3] == null) {
            alert("FAIL loadPNHistory: Counter nicht gesetzt!");
        }
    }

    // können noch mehr Beiträge geladen werden?
    if (a.counter[0] < a.counter[1] || a.counter[2] < a.counter[3]) {
        a.innerHTML = format("Ein=(%s-%s) Aus=(%s-%s) Lade Nachrichten älter als\n%s",
            a.counter[1], Math.min(a.counter[0], a.counter[1]),
            a.counter[3], Math.min(a.counter[2], a.counter[3]),
            formatDateDifference(instamp, a.outstamp));
    } else {
        if (instamp > a.outstamp) {
            instamp = a.outstamp;
        }
        a.outerHTML = "Keine Nachrichten älter als" + wochentag[instamp.getDay()] + formatDate(instamp);
    }
}


function resetPNHistory(a, userId, userName) {
    var targetTable = document.getElementById("PNHistory");
    if (targetTable) {
        targetTable.parentNode.removeChild(targetTable);
    }

    if (debug) {
        a.nextSibling.innerHTML += "\nreset userId=" + userId + " userName=" + userName;
    }
    viaXpath0("//INPUT[@name='submit' and @type='SUBMIT' and @accesskey='S' and @value]", document.creator)
        .value = userId ? "Nachricht an " + userName + " abschicken" : "Suche '" + userName + "' und schicke Nachricht";
    document.title = "KG:" + userName + " (neue PN)";
    a.userId = userId;
    a.userName = userName;
//     a.counter = [860, 888, 740, 761];  /* TODO
    a.counter = [0, null, 0, null]; // */
    a.content = [];
    a.outstamp = new Date();

    loadPNHistory();
}


function initPNHistory() {
    var a = document.getElementById("loadPNHistory");
    var pm_empfaenger = document.creator.pm_empfaenger;
    var searchid2     = document.creator.searchid2;
    if (!a) {
        document.creator.message.cols = 120;
        document.creator.message.rows =  40;
        a = document.createElement("A");
        a.id = "loadPNHistory";
        if (searchid2) searchid2.addEventListener("change", initPNHistory);
        document.creator.adrsel .addEventListener("change", initPNHistory);
        pm_empfaenger           .addEventListener("blur"  , initPNHistory);
        a                       .addEventListener("click" , loadPNHistory);
        a.href = "javascript:void(0)";
        viaXpath0("//INPUT[@value='Nachricht abschicken']//ancestor::CENTER").appendChild(a);

        var pre = document.createElement("PRE");
        pre.innerHTML = "\n";
        a.parentNode.appendChild(pre);
    }
    pm_empfaenger = pm_empfaenger.value;

    // 1. falls ein Empfänger gesucht und ausgewählt wurde
    if (searchid2) {
        if (0 == searchid2.selectedIndex) {
            // suche eine exakte Übereinstimmung
            var reset = true;
            for (var i = 1; i < searchid2.options.length; i++) {
                if (2 == searchid2.options.length
                    || pm_empfaenger.toLowerCase() == searchid2.options[i].innerHTML.toLowerCase() ) {
                    // wenn eindeutig, nimm ihn sofort (feuert nicht den onchange-Listener)
                    searchid2.value = searchid2.options[i].value;
                    reset = false;
                    break;
                }
            }
            if (reset) {
                a.userId = null;
                a.userName = null;
            }
        }
        // FALL-THROUG, ausdrücklick kein else!
        if (0 < searchid2.selectedIndex) {
            var userId = parseInt(searchid2.options[searchid2.selectedIndex].value);
            if (!a.userId || a.userId != userId) {
                resetPNHistory(a, userId, searchid2.options[searchid2.selectedIndex].innerHTML);
            }
        }
    }

    // 2. falls ein Empfänger über die URL identifiziert wurde (z.B. Nachricht unter Beitrag bzw, Antwort / Zitat auf bestehende PN)
    if (!a.userId && reg_pnUserId.exec(window.location.search)) {
        resetPNHistory(a, parseInt(RegExp.$1), pm_empfaenger);
    }

    // 3. Der Suchtext für den Empfänger wird zusammen mit der PN an den Server gesendet (hoffentlich eindeutig)
    if (!a.userId && (!a.userName || pm_empfaenger.toLowerCase() != a.userName.toLowerCase()) || a.userName == null) {
        resetPNHistory(a, null, pm_empfaenger);
    }
}


// Diverse Seiten dienen der Eingabe neuer Daten: nur den Titel setzen und Skript beenden.
var tagArray = document.getElementsByTagName("FORM");
if (tagArray.length > 0) {
    tagArray = tagArray[0].getElementsByTagName("INPUT");
    if (tagArray.length > 0) {
        if (tagArray[0].name == "searchstring") {
            // Sollte die Suche nach allen Beiträgen nicht mehr funktionieren, kann hiermit eine aktuelle Suchanfrage
            // ins Addressfeld geholt werden (siehe auch: formatProfile()).
            // document.getElementsByTagName("FORM")[0].method = "GET";
            document.title = "KG:Suche";
            // Erlaube die gezielte Suche im Moderatoren-Brett
            var staff = viaXpath0("//SELECT/OPTION[@value=2399]");
            if (debug && staff) {
                var mod = document.createElement("OPTION");
                mod.setAttribute("value", "2439");
                mod.innerHTML = "Moderatoren-Ecke";
                staff.parentNode.insertBefore(mod, staff);
            }
            logTime();
            return;
        }
        if (tagArray.length > 1) {
            pimpEditor();
            if (tagArray[1].name == "subject") {
                document.title = "KG:" + tagArray[1].value + " (Beitrag ändern)";
                logTime();
                return;
            }
            if (tagArray[1].name == "pm_empfaenger") {
                initPNHistory();
                logTime();
                return;
            }
            if (tagArray[1].name == "mailto") {
                var an = viaXpath0("//FORM//B").nextSibling.data;
                document.title = "KG:" + an + " (neue eMail)";
                logTime();
                return;
            }
            if (tagArray.length > 3) {
                if (tagArray[3].name == "subject") {
                    tagArray = tagArray[3].value;
                    if (tagArray) {
                        document.title = "KG:" + tagArray.replace(/^RE:\s*/, "") + " (neue Antwort)";
                        formatThreadHistory();
                    } else {
                        document.title = "KG:neues Thema";
                    }
                    logTime();
                    return;
                }
            }
        }
    }
}


if (window.location.search.indexOf("action=memberlist") > 0) {
    if (window.location.search.indexOf("list=mlletter") > 0) {
        document.title = "KG:" + (/letter=([%a-z])/.exec(window.location.search) ? RegExp.$1 : "a") + "-Mitglieder";
    } else if (window.location.search.indexOf("list=mltop") > 0) {
        document.title = "KG:Top Mitglieder";
    } else if (window.location.search.indexOf("list=mlall") > 0) {
        document.title = "KG:alle Mitglieder";
    }

    if (debug) {
        // zähle Vorkommen von email.gif
        var em = viaXpath("//TD/FONT/A[IMG[contains(@src, '/images/theme1/email.gif')]]");
        for (var i = 0; i < em.snapshotLength; ) {
            em.snapshotItem(i).parentNode.appendChild(document.createTextNode(" " + ++i));
        }
    }

    logTime();
    return;
} else if (window.location.pathname.match(/print_5_\d+.html/)) {
    // TODO Statistik mit Anzahl Beiträge je User, je Status, (je Gruppe: auto, sub, top, whitelist forenleitung?)
    // iteriere über alle Beiträge
    var content = [];
    var datum = viaXpath("//TABLE//P[@align='right' and contains(text(),'geschrieben von')]");
    var header;
    for (i = 0; i < datum.snapshotLength; i++) {
        header = datum.snapshotItem(i);
        var time = parseDate(header.innerHTML);
        header.innerHTML = RegExp.$1 + wochentag[time.getDay()] + formatDate(time);
        header = header.parentNode.previousSibling;
        header.innerHTML = "<A name='" + (i + 1) + "'>" + header.innerHTML + "</A>";

        // Inhalt in neues Div packen, um ein umfassendes HTML-Element zu haben.
        var current = document.createElement("DIV");
//         current.appendChild(document.createTextNode(i));
        current.time = time.getTime();
        header = viaXpath0("ancestor::TABLE", header);
        header.parentNode.insertBefore(current, header.nextSibling);
        var next = i + 1 < datum.snapshotLength ? viaXpath0("ancestor::TABLE", datum.snapshotItem(i + 1)) : null;
        while (current.nextSibling != next) {
            current.appendChild(current.nextSibling);
        }

        // Sortiere Beiträge nach Datum (Bug von einer alten Datenkonvertierung des Forums)
        j = i - 1;
        while (0 <= j && time.getTime() < parseInt(content[j].time)) {
            header = content[j].previousSibling;
            // Markiere den zu früh einsortieren Beitrag mit der zu niedrigen Nummer für sein junges Alter
            if (!header.title) {
                header.setAttribute("bgcolor", lightRed);
                header.title = "Umsortiert entsprechend Datum!\nVorher Platz: " + (j + 1);
            }
            current.parentNode.insertBefore(current.previousSibling, header);
            current.parentNode.insertBefore(current                , header);
            j--;
        }
        // current in die bislang bearbeiteten Beiträgen einsortieren
        content.splice(j + 1, 0, current);
    }

    var t = document.title;
    if (content.length > 0) {
        viaXpath0(".//A[@name]", content[0].previousSibling).innerHTML.search(/\d+\. (RE: )?(.*)$/);
        t = RegExp.$2;
    }
    document.title = "KG-" + t + " (Drucken)";

    // Zitat mit seiner Herkunft annotieren
    for (i = 0; i < content.length; i++) {
        viaXpath0(".//P[@align='right']", content[i].previousSibling).childNodes[0].data
            .match(/^geschrieben von (.+) am (.+)$/);
        content[i].author = RegExp.$1;
        content[i].date = RegExp.$2;
        // Beitragsnummer entspricht nicht mehr der Position i (umsortiert)
        header = viaXpath0(".//A[@name]", content[i].previousSibling);
        header.name = i + 1;
        header.innerHTML = (i + 1) + header.innerHTML.substr(header.innerHTML.indexOf("."));
        content[i].ref = i + 1;
        content[i].num = content[i].ref;
        highlightQuote(content, i);
    }
    if (debug && quoteOverview) {
        quoteOverview.innerHTML +=
            format("<TR><TD colspan=4>hit=%s partial=%s miss=%s</TD></TR>%n", hit, partial, miss);
    }
    logTime();
    return;
}


// maxCount aus der URL, aus der Unterseiten-Navigation oder null
async function threadContentTouchup(threadId, count, maxCount) {
    // Markiere noch nicht gelesene Beiträge als neu.
    var entry = await getOrCreateThreadEntry(threadId);

    // Extrahiere zusätzliche Sprunganweisungen des Suchergebnisses.
    var gotoDate = null, smaller = false, bigger = false, closest = null, jumpTo = null;
    var loc = decodeURI(window.location.search);
    if (loc.search(/.*goto=new/) >= 0) {
        if (entry.maxCount >= beitraege_pro_seite) {
            // springe zur Seite mit dem ersten neuen Beitrag
            var n = Math.min(entry.maxCount, maxCount);
            loc = window.location.toString();
            loc = loc.replace(
                /(.*\/display_5_\d*_\d+).*(\.html).*/,
                "$1_" + Math.max(entry.maxCount, maxCount) + "_" + (n - n % beitraege_pro_seite) + "$2");
            window.location.replace(loc);
            return;
        }
    } else if (loc.search(/.*goto=([^-]+)-([^&]+)/) >= 0) {
        // var gotoUser = RegExp.$2;
        // Reihenfolge: parseDate führt selbst reguläre Suchen durch und verwirft den User.
        gotoDate = parseDate(RegExp.$1);
    }

    // Sprung in print_5_ mit aktuellem Zählerstand als Marke
    if (count > 0) {
        viaXpath0("//IMG[contains(@src,'/images/theme1/print.gif')]/parent::A").href += "#" + (count + 1);
    }

    // Biete an, das Lesezeichen zu löschen
    var abo = viaXpath("//IMG[contains(@src,'/images/theme1/notification.gif')]/parent::A");
    for (var i = 0; i < abo.snapshotLength; i++) {
        var delEntryNode = document.createElement("A");
        delEntryNode.href = "javascript:void(0);";
        deleteParameter = threadId;
        delEntryNode.addEventListener("click", deleteEntry);
        delEntryNode.innerHTML = "<FONT size=1>Lesezeichen löschen ...</FONT>";
        abo.snapshotItem(i).parentNode.appendChild(delEntryNode);
    }

    // Suche nach Pausen zwischen Beiträgen, die mehr als einen Monat auseinander liegen
    var timeStamps = viaXpath(
        "//IMG[contains(@src,'/images/theme1/icon3.gif')]/parent::A/preceding-sibling::FONT[@size]/B[1]",
        null, true);
    var lastDate = null, time, openEnd = false, postId = -1, boardId = -1;
    var beitraege = timeStamps.snapshotLength, breaks = 0;
    var content = [];
    var reg_newPost = /action=display/,
        reg_nachrichtenId = /.*nachrichtenid=(\d+)&boardid=(\d+).*/;
    for (i = 0; i <= beitraege; i++) {
        if (i < beitraege) {
            var node = timeStamps.snapshotItem(i);
            viaXpath0("parent::FONT/following-sibling::A", node).href.match(reg_nachrichtenId);
            postId = parseInt(RegExp.$1);
            boardId = parseInt(RegExp.$2);
            content[i] = viaXpath0("ancestor::TABLE/ancestor::TR/following-sibling::TR/TD", node);

            // Zitat mit seiner Herkunft annotieren
            var target = viaXpath("../preceding-sibling::TR", content[i]);
            target = target.snapshotItem(target.snapshotLength - 1);
            // console.log(format("%s: %s%n%s", i, target, target.innerHTML));
            content[i].author = viaXpath0("./TD[@rowspan=3]//B",                     target).innerHTML;
            content[i].date   = viaXpath0("./TD[@colspan=2]//TD[@align='right']//B", target).nextSibling.data;
            content[i].ref    = viaXpath0("./TD[@rowspan=3]/A[@name]",               target).name;
            content[i].num    = i + 1 + count;
            highlightQuote(content, i);

            time = parseDate(node.nextSibling.data);
            if (threadId
                && (entry.lastDate < time                       // aktuellere Beiträge gefunden
                || entry.maxCount <  count + i + 1              // mehr Beiträge gefunden
                // Zeitstempel des letzten Beitrages stimmt nicht
                || entry.maxCount == count + i + 1 && (entry.lastDate != time.getTime() || entry.postId != postId))) {
                if (entry.postId === 0 || window.location.search.search(reg_newPost) >= 0) {
                    // Beitrag beim Anfügen einer neuen Antwort sofort als gelesen markieren
                    entry.direct = true;
                }
                entry.lastDate = time.getTime();
                entry.maxCount = count + i + 1;
                entry.postId = postId;
                entry.changed = true;
                if (!jumpTo) {
                    // niemals den ersten Beitrag auf einer Seite anspringen
                    jumpTo = i === 0 ? -1 : entry.postId;
                }

                // Beitrag als neu markieren
                var img = viaXpath0("parent::FONT/parent::TD/preceding-sibling::TD/IMG", node);
                img.setAttribute("srcFallback", img.src);
                img.src = "/images/theme1/new.gif";
            }
            if (gotoDate) {
                var diff = gotoDate - time;
                if (diff < 0) {
                    smaller = true;
                    diff = -diff;
                } else if (diff > 0) {
                    bigger = true;
                } else {
                    // gefunden -> nicht weiter suchen
                    gotoDate = null;
                }
                if (!closest || diff < closest) {
                    closest = diff;
                    // niemals den ersten Beitrag auf einer Seite anspringen
                    jumpTo = i === 0 ? -1 : postId;
                }
            }
        } else if (i < beitraege_pro_seite || !maxCount || beitraege + count >= maxCount) {
            // Spanne vom allerletzten Beitrag eines Themas bis zum aktuellen Datum
            time = new Date();
            openEnd = true;

            if (entry.maxCount > beitraege + count) {
                // einige Beiträge wurden gelöscht
                // oder der Zähler in der URL ist falsch
                // oder ein Link auf eine Unterseite wurde in einen Beitrag / in eine Signatur kopiert
                var urlCount = reg_beitrag.exec(window.location);
                if (   urlCount // es ist eine Standard-URL
                    && urlCount[6] // mit 5 Stellen (..._maxAnzahl_offset.html)
                    && beitraege == beitraege_pro_seite // normaler Seitenumbruch nicht von Thread-Ende unterscheidbar
                    && parseInt(urlCount[5]) < beitraege + count // fragliche maxAnzahl nicht vertrauenswürdig
                ) {
                    // weise auf die fragwürdige Navigation hin
                    var url = "/" + urlCount[1] + "_5_" + urlCount[2] + "_" + urlCount[3] + "_" + entry.maxCount + "_";
                    urlCount = ((parseInt(urlCount[5]) / beitraege_pro_seite) | 0) + 1;
                    var ziel = ((entry.maxCount        / beitraege_pro_seite) | 0);

                    var navigation = viaXpath0("//TD[FONT/A/IMG[contains(@src, '/images/theme1/reply.gif')]]"
                        + "/../TD/TABLE//FONT[not(text() or node()) or FONT/STRONG[contains(text(), '[" + urlCount + "]')]]");
                    for (i = urlCount; i < Math.min(urlCount + 4, ziel + 1); ) {
                        navigation.innerHTML += ' <A href="' + url + (beitraege_pro_seite * i) + '.html">' + ++i + '?</A>';
                    }
                    if (1 == urlCount) {
                        // keine Navigationsstruktur vorhanden
                        navigation.innerHTML =
                            'Seiten (' + (ziel + 1) + ') <IMG src="/images/theme1/end.gif"> <STRONG>« [' + urlCount + ']</STRONG>'
                            + navigation.innerHTML
                            + ' <A href="' + url + (beitraege_pro_seite * urlCount) + '.html">»?</A>'
                            + ' <A href="' + url + (beitraege_pro_seite * ziel) + '.html"><IMG src="/images/theme1/end.gif">?</A>';
                    } else {
                        // bestehende Navigation erweitern
                        navigation = viaXpath0("../following-sibling::TD/FONT[STRONG[text()='»']]", navigation);
                        navigation.innerHTML = '<A href="' + url + (beitraege_pro_seite * urlCount) + '.html">' + navigation.innerHTML + '?</A>';

                        navigation = viaXpath0("../following-sibling::TD[IMG[contains(@src, '/images/theme1/end.gif')]]", navigation);
                        navigation.innerHTML = '<A href="' + url + (beitraege_pro_seite * ziel) + '.html">' + navigation.innerHTML + '?</A>';
                    }

                    // unterdrücke den Trennbalken und die Auswertung des Thread-Endes
                    break;
                } else {
                    if (debug) {
                        // Zeige den letzten Beitrag
                        window.location.hash = "#" + postId;
                        alert(format("%s Beiträge gelöscht, Lesezeichen überschreiben:\nalt: %s\nneu: %s\nTab schließen zum Abbrechen!",
                            entry.maxCount - beitraege - count, entry,
                            new Entry(threadId, beitraege + count, postId, lastDate.getTime())
                        ));
                    }
                    entry.maxCount = beitraege + count;
                    entry.postId = postId;
                    entry.lastDate = lastDate.getTime();
                    entry.changed = true;
                    entry.direct = true;
                }
            }
        } else {
            // letzter Beitrag dieser Seite, aber nicht auf der letzten Seite!
            break;
        }

        // Trennbalken für zeitliche Lücken zwischen Beiträgen
        if (lastDate) {
            var diff = time - lastDate.getTime();
            if (openEnd || diff >  30 * millisDay) {
                var spanne = "<CENTER>";
                if (       diff > 365 * millisDay) {   spanne +=                  "<BR/><HR/><BR/><P>"
                        + (diff / 365 / millisDay).toFixed(2) + " Jahre später ...</P><BR/><HR/><BR/>";
                } else if (diff >  30 * millisDay) {   spanne +=                   "<BR/><P>"
                        + (diff /  30 / millisDay).toFixed(2) + " Monate später ...</P><BR/>";
                } else {                               spanne +=
                          (diff /       millisDay).toFixed(2) + " Tage später ...";
                }
                spanne += "</CENTER>"
                if (debug && i == beitraege && maxCount && maxCount != entry.maxCount && !entry.changed) {
                    jumpTo = entry.postId;
                    if (maxCount > entry.maxCount) {
                        spanne += format("<BR/>Beitragscounter VERRINGERN %s &gt; %s<BR/>"
                            + "<OL type='a'>"
                            + "<LI>Diese Seite wurde zuletzt geladen? (Wählt diesen Thread aus!)</LI>"
                            + "<LI><A href='/?forumid=5&action=delpost&nachrichtenid=790185' target='_blank'>"
                                + "<IMG src='/images/theme1/delete.gif'> "
                                + "Lösche %s Test-Beiträge</A> (altes Testposting x-mal löschen)</LI>"
                            + "<LI><A href='/threads_5_%s.html'>lade Brett-Übersicht</A></LI>"
                            + "</OL>",
                            maxCount, entry.maxCount, maxCount - entry.maxCount, boardId);
                    } else {
                        spanne += format("<BR/>Beitragscounter ERHÖHEN %s &lt; %s<BR/>"
                            + "<OL type='I'>"
                            + "<LI>Wiederhole %3$s mal:"
                                + "<OL type='a'>"
                                + "<LI><A target='_blank' %5$s Schreibe Test-Beiträge</A> (+1 im Zähler)</LI>"
                                + "<LI><A target='_blank' href='/threads_5_%4$s.html'>Lade Brett-Übersicht in neuem Tab</A> (Löschfunktion verwirren)</LI>"
                                + "<LI><IMG src='/images/theme1/delete.gif'/> Lösche den gerade erstellten Test-Beitrag aus a) und schließe den extra-Tab</LI>"
                                + "</OL>"
                            + "</LI>"
                            + "<LI><A %5$s Schreibe echten Beitrag</A>, um alle Caches zu aktualisieren</LI>"
                            + "</OL><HR/><OL type='I'>"
                            + "<LI>Alternativ wiederhole %3$s mal:"
                                + "<OL type='a'>"
                                + "<LI><A target='_blank' href='/threads_5_%4$s.html'>Lade Brett-Übersicht in neuem Tab</A> (Löschfunktion verwirren)</LI>"
                                + "<LI><IMG src='/images/theme1/delete.gif'/> Lösche einen alten Beitrag (möglichst nicht den letzten)</LI>"
                                + "</OL>"
                            + "</LI>"
                            + "<LI>%6$s aktualisiere den Cache</A></LI>"
                            + "</OL>",
                            maxCount, entry.maxCount, entry.maxCount - maxCount, boardId,
                            abo.snapshotItem(0).previousSibling.outerHTML.replace("</?A>?"),
                            viaXpath0("//IMG[contains(@src, '/images/theme1/del_cache.gif')]/parent::A").outerHTML.replace("</A>"));
                    }
                }
                lastDate = document.createElement("TR");
                lastDate.innerHTML =
                    "<TD colspan=2><FONT color='white'>" + spanne + "</FONT></TD>";
                spanne = viaXpath0(
                    "//FONT[@color='#FFFFFF']/parent::TD[@nowrap and @bgcolor='#008080']/parent::TR/following-sibling::TR["
                    + (3 * i + breaks++) + "]");
                spanne.parentNode.insertBefore(lastDate, spanne.nextSibling);
            }
        }
        lastDate = time;
    }

    // keinen passenden Beitrag auf dieser Seite gefunden -> binäre Suche der Seiten
    if (gotoDate) {
        if (smaller != bigger && maxCount) {
            var lo = 0, hi = maxCount;
            if (window.location.search.search(/&bin=(\d+)-(\d+)/) >= 0) {
                lo = parseInt(RegExp.$1);
                hi = parseInt(RegExp.$2);
            }
            if (smaller) {
                hi = count;
            } else {
                lo = count + beitraege;
            }
            if (0 <= lo && lo <= hi && hi <= maxCount) {
                var middle = (Math.floor(hi / beitraege_pro_seite) - Math.floor(
                    (Math.floor(hi / beitraege_pro_seite) - Math.floor(lo / beitraege_pro_seite)) / 2))
                    * beitraege_pro_seite;
                if (beitraege_pro_seite <= middle && middle == hi) {
                    // Seite  1:   0- 20 von 220
                    // Seite  7: 120-140 von 220
                    // Seite 10: 180-200 von 220
                    // Seite 12: 220-240 von 220 -> per Definition leer
                    // der Beitrag 220 steht als letzter noch auf Seite 11
                    middle -= beitraege_pro_seite;
                }
                middle = window.location.href.replace(
                    /^(.*display_5_\d+_\d+)(_\d+_\d+)?(\.html.*?)(&bin=\d+-\d+)?$/,
                    "$1" + (0 === middle ? "" : "_" + maxCount + "_" + middle) + "$3&bin=" + lo + "-" + hi);
                if (middle.indexOf(window.location.pathname) < 0) {
                    window.location.replace(middle);
                    return;
                }
            }
        }
    }
    if (jumpTo > 0) {
        // Zum ersten ungelesenen Beitrag springen
        window.location.hash = "#" + jumpTo;
    }

    if (entry.changed) {
        if (entry.direct) {
            // Falls ein Thema noch nicht bekannt ist, wird es sofort registriert. Schützt davor, dass ein anderer
            // Reiter für die nächste Seite aufgemacht werden kann, der noch nicht sieht, dass dieser Reiter bereits bis
            // zum Ende als gelesen markiert ist. Außerdem kann so schnell angelernt werden, was nicht mehr neu ist.
            // Dazu muss jedes Thema nur einmal kurz geöffnet werden.
            saveThreadEntry(entry);
        }
        safeTimer = window.setTimeout(function() {
            // Alle new-Bilder wieder ins Original zurückverwandeln
            var newImg = viaXpath("//IMG[@srcFallback]");
            for (var i = newImg.snapshotLength - 1; 0 <= i; i--) {
                var ni = newImg.snapshotItem(i);
                ni.src = ni.getAttribute("srcFallback");
                ni.removeAttribute("srcFallback");
            }
            if (!entry.direct) {
                saveThreadEntry(entry);
            }
            entry.changed = null;
            entry.direct = null;
            safeTimer = null;
        }, lese_verzoegerung * 1000);
    }

    // Syntax-Highlight für Beiträge die nachträglich editiert wurden
    var edit = viaXpath("//FONT/child::*[self::STRONG or self::B]/FONT[@size]");
    var reg_editNew    = /(.*Dieser Eintrag wurde zuletzt) von (.+?) am ([\d\.]+) um ([\d:]+) ge.{1,2}(ndert.*)/,
        reg_editOld    = /(.*Diese Nachricht wurde) am ([\d\.]+) um ([\d:]+) von (.+?) ge.{1,2}(ndert.*)/,
        reg_dateSimple = /(\d{2}\.\d{2}\.\d{2,4})/;
    for (i = edit.snapshotLength - 1; 0 <= i; i--) {
        var message = edit.snapshotItem(i);
        var anfang = null, aenderer, datum, zeit, ende;
        if (message.innerHTML.search(reg_editNew) >= 0) {
            anfang   = RegExp.$1;
            aenderer = RegExp.$2;
            datum    = RegExp.$3;
            zeit     = RegExp.$4;
            ende     = RegExp.$5;
        } else if (message.innerHTML.search(reg_editOld) >= 0) {
            anfang   = RegExp.$1;
            datum    = RegExp.$2;
            zeit     = RegExp.$3;
            aenderer = RegExp.$4;
            ende     = RegExp.$5;
        }
        if (anfang) {
            datum = formatDate(parseDate(datum + " 00:00"));
            datum = datum.substr(0, datum.indexOf(" "));
            var parsedZeit = parseDate(message.innerHTML);
            var einsteller =     viaXpath0("ancestor::TR[1]/preceding-sibling::TR[1]//FONT[@size]/B", message).innerHTML;
            var eingestelltRaw = viaXpath0("ancestor::TR[1]/preceding-sibling::TR[1]//TABLE//FONT[@size]/B[2]", message,
                true).previousSibling.data;
            eingestelltRaw.search(reg_dateSimple);
            var einstelldatum = RegExp.$1;
            var einstellzeit = parseDate(eingestelltRaw);
            var difference = formatDateDifference(einstellzeit, parsedZeit);
            var colorSpan = "<SPAN style='background-color:%s' title='vgl. %s'>%s</SPAN>";

            message.innerHTML = format("%s von %s am%s%s um %s ge&auml;%s",
                anfang,
                einsteller == aenderer ? aenderer
                    : format(colorSpan, lightYellow, einsteller, aenderer),
                wochentag[parsedZeit.getDay()],
                einstelldatum == datum ? datum
                    : format(colorSpan, lightYellow, difference, datum),
                // ignoriere bis zu 10 Minuten zum Ändern des Beitrages
                einstellzeit.getTime() + aenderungen_markieren * 60000 > parsedZeit ? zeit
                    : format(colorSpan, lightYellow, difference, zeit),
                ende);
        }
    }
}


// biete direkte Links als Referenz zum Kopieren an
function insertLinks(threadId, count, maxCount, searchstring) {
    // Einfügen links neben/vor dem Button "Zitieren" (ist gleichzeitig Quelle für die IDs)
    var zitieren = viaXpath("//A[contains(@href, 'action=reply')]/IMG[contains(@src, '/quote.gif')]/parent::A", null, true);
    var threadpath, offset = count - (count % beitraege_pro_seite);
    var reg_ids = /nachrichtenid=(\d+)&threadid=(\d+)&boardid=(\d+)/;
    for (var i = 0; i < zitieren.snapshotLength; i++) {
        var z = zitieren.snapshotItem(i);
        if (z.href.search(reg_ids) < 0) {
            continue;
        }
        var nachrichtenid = RegExp.$1;
        if (!threadpath) {
            threadId = parseInt(RegExp.$2);
            // absoluter Pfad ohne Site (überschreibt fehlerhafte Pfade mit mehreren /)
            threadpath = "/display_5_" + RegExp.$3 + "_" + RegExp.$2;
        }
        z = z.parentNode;

        var reference = document.createElement("A");
        reference.title = "Link auf diesen Beitrag" + (searchstring ? " ohne Suchergebnisse" : "");
        reference.href = threadpath + "_" + nachrichtenid + ".html#" + nachrichtenid;
        count++;
        reference.appendChild(document.createTextNode("#" + padding(count, 0, 2)));
        z.insertBefore(reference, z.firstChild);

        if (searchstring) {
            z.insertBefore(document.createTextNode(" - "), z.firstChild);
            reference = document.createElement("A");
            reference.style = "background-color:yellow"; // lightYellow nicht kräftig genug hinter einzelnen Ziffern
            reference.title = decodeURI(searchstring);
            reference.href =
                threadpath + "_" + Math.max(offset + beitraege_pro_seite, maxCount) + "_" + offset + ".html"
                    + searchstring + "#" + nachrichtenid;
            reference.appendChild(document.createTextNode("hl" + padding(count, 0, 2)));
            z.insertBefore(reference, z.firstChild);
        }
    }

    // URL auf alte Smilies geht nicht mehr
    var smilies = null;
    var oldImag = viaXpath("//IMG[contains(@src, 'mages/smilies/cwm')]");
    for (i = 0; i < oldImag.snapshotLength; i++) {
        var img = oldImag.snapshotItem(i);
        if (!smilies) {
            smilies = /^.*mages\/smilies\/cwm/;
        }
        img.src = img.src.replace(smilies, "/images/smilies/cwm");
    }
    return threadId;
}


var debugBoard;
var debugString;


function markBeitragsCounter(ziel, entry, beitraege) {
    // diese Zeile erhält keine weiteren Updates mehr
    ziel.removeAttribute("threadId");

    if (debug) {
        var threadTitle = viaXpath0("FONT/A[@href]/B", ziel);
        ziel = viaXpath0("following-sibling::TD[2]", ziel);
        ziel.title = "Abweichende Anzahl Beiträge " + entry.maxCount;
        ziel.setAttribute("bgcolor", lightRed);
        ziel.children[0].innerHTML += " <IMG src='/images/theme1/icon3.gif'/>"; // gelbes Ausrufezeichen

        debugString.appendChild(document.createTextNode(
            // baue eine Tabellen-Zeile für eine private Nachricht zum Melden der falschen Beitrags-Zählerstände
            "[/td][/tr][tr][td]" + beitraege
            + "[/td][td]" + entry.maxCount
            + "[/td][td]" + entry.threadId
            + "[/td][td][url=" + debugBoard + entry.threadId + "_" + entry.postId + ".html#" + entry.postId + "]"
            + threadTitle.innerHTML
            + "[/url]"
        ));
        debugString.appendChild(document.createElement("BR"));
    }
}


async function colorizeBoardOverview() {
    var row = viaXpath("//TD[@threadId]");
    for (var i = row.snapshotLength - 1; 0 <= i ; i--) {
        var ziel = row.snapshotItem(i);
        var entry = await getThreadEntry(parseInt(ziel.getAttribute("threadId")));
        if (entry) {
            var beitraege = parseInt(ziel.getAttribute("beitraege"));
            var lastDate  = parseInt(ziel.getAttribute("lastDate"));
            if (entry.lastDate == lastDate || entry.maxCount == beitraege) {
                ziel.title = "Letzter Beitrag wurde bereits gelesen!";
                ziel.setAttribute("bgcolor", lightCyan);
                if (debug) {
                    if (entry.lastDate != lastDate) {
                        // diese Zeile erhält keine weiteren Updates mehr
                        ziel.removeAttribute("threadId");
                        ziel = viaXpath0("following-sibling::TD[4]", ziel);
                        ziel.title = "Abweichendes Datum des letzten Beitrages\n" + formatDate(new Date(entry.lastDate));
                        ziel.setAttribute("bgcolor", lightRed);
                    } else if (entry.maxCount != beitraege) {
                        markBeitragsCounter(ziel, entry, beitraege);
                    }
                }
            } else {
                var stamp = new Date(entry.lastDate);
                if (ziel.getElementsByTagName("A")[0].innerHTML.indexOf("verschoben:") >= 0) {
                    ziel.title = format(
                        "Mindestens %s (=%s-%s) neue Beiträge seit dem Verschieben!\nGelesen bis: %s%s",
                        beitraege - entry.maxCount, beitraege, entry.maxCount,
                        wochentag[stamp.getDay()], formatDate(stamp));
                    ziel.setAttribute("bgcolor", lightCyan);
                } else if (lastDate < entry.lastDate) {
                    ziel.title = format("Übersicht veraltet: %s (=%s-%s) neue Beiträge\nGelesen bis: %s%s",
                        beitraege - entry.maxCount, beitraege, entry.maxCount,
                        wochentag[stamp.getDay()], formatDate(stamp));
                    ziel.setAttribute("bgcolor", lightYellow);
                } else {
                    if (beitraege < entry.maxCount) {
                        markBeitragsCounter(ziel, entry, beitraege);
                    }
                    ziel.title = format("neue Beiträge: %s (=%s-%s)\nGelesen bis: %s%s",
                        beitraege - entry.maxCount, beitraege, entry.maxCount,
                        wochentag[stamp.getDay()], formatDate(stamp));
                    ziel.setAttribute("bgcolor", lightGreen);
                }
            }
        }
    }
    // immer wieder aktualisieren, solange das Brett noch offen ist
    window.setTimeout(colorizeBoardOverview, 15000);
}


async function formatBoardOverview(board) {
    document.title = "KG-" + board.replace(/\(Moderator.*/, "");
    if (window.location.search) {
        window.location.search.search(/&boardid=(\d+)/);
        board = RegExp.$1;
    } else {
        board = null;
    }
    var index = null;
    if (!board) {
        window.location.pathname.search(/threads_5_(\d+)(_\d+_(\d+))?/);
        board = RegExp.$1;
        index = RegExp.$3;
    }
    document.title += " [" + (index ? index / 30 + 1 : 1) + "]";
    if (debug) {
        debugString = document.createElement("FONT");
        debugString.size = 1;
        debugString.innerHTML =
            "<A href='/?forumid=5&action=mysite&mysite=newpm&searchid2=79596&searchid=1'>PN an Bulli</A><BR>";
        debugString.appendChild(document.createTextNode(
            "[table][tr][td]angezeigt[/td][td]tatsächlich[/td][td]ThreadId[/td][td]"
            + "Board: [url=" + window.location + "]"
            + document.title.substr(3) + "[/url]"));
        debugString.appendChild(document.createElement("BR"));
        document.body.appendChild(debugString);
        document.body.appendChild(document.createTextNode("[/td][/tr][/table]"));
        document.body.appendChild(document.createElement("BR"));
    }
    board = "/display_5_" + board + "_";
    if (debug) {
        debugBoard = preferredHost + board;
    }

    // iteriere Themen-Zeilen und ändere verschiedene Ziel-Zellen der Übersichts-Tabelle
    var row = viaXpath("//TR/TD[position()=5 and @bgcolor='#dbdbdb']/FONT[@size]");
    var reg_markup      = /<.*?>|\./g,
        reg_parenthesis = /\(/g,
        reg_thread      = /_(\d+)\.html/,
        reg_von         = /\s*von\s*/;
    var anzahlBeitraege = 0,
        anzahlLesungen = 0;
    for (var i = row.snapshotLength - 1; 0 <= i ; i--) {
        var zaehler = row.snapshotItem(i),
            ziel = viaXpath0("ancestor::TD[1]/following-sibling::TD[@bgcolor='#dbdbdb']/FONT[@size]", zaehler);
        // Bilde Verhältnis Gelesen / Beiträge²
        var beitraege = eval(zaehler.innerHTML.replace(reg_markup, "").replace(reg_parenthesis, "+("));
        anzahlBeitraege += beitraege;
        var lesungen = parseInt(ziel.innerHTML.replace(reg_markup, ""));
        anzahlLesungen += lesungen;
        ziel.innerHTML += format(" <font color='%s'>/ %s</font>",
            beitraege < 5 ? "#aaa" : beitraege < 10 ? "#555" : "black",
            (lesungen / beitraege / beitraege).toFixed(1));
        ziel = viaXpath0("ancestor::TD[1]/following-sibling::TD[@bgcolor='#eeeeee']/FONT[@size]", ziel);

        // annotiere den Wochentag
        var lastDate = parseDate(ziel.innerHTML);
        ziel.innerHTML = wochentag[lastDate.getDay()].substr(1, 2) + " " + formatDate(lastDate)
            + ziel.innerHTML.substr(ziel.innerHTML.indexOf("<"));
        ziel = ziel.getElementsByTagName("A")[0];
        ziel.href.search(reg_thread);
        var threadId = RegExp.$1;
        if (debug) {
            var b = beitraege;
            var entry = await getThreadEntry(parseInt(threadId));
            if (entry && b < entry.maxCount) {
                b = entry.maxCount;
            }
            zaehler.innerHTML = format("<A href='%s%s_%s_%s.html' target='_blank'>%s</A>",
                // beitraege evtl. < b -> beitraege steuert nur Navigation unter Thread
                // Beitrags-Zahl aus Board transportieren für Zählerstand-Korrektur
                board, threadId, beitraege, b - 1 - (b - 1) % beitraege_pro_seite, zaehler.innerHTML);
        }
        // letzten Beitrag direkt anspringen aus dem Brett heraus (höhere Priorität als nur "Neu")
        ziel.href = format("%s%s%s.html?goto=%s-%s",
            board,
            threadId,
            beitraege <= beitraege_pro_seite ? "" : "_" + beitraege + "_" + (beitraege - 1 - (beitraege - 1) % beitraege_pro_seite),
            formatDate(lastDate).replace(reg_space, "_"),
            ziel.innerHTML.replace(reg_von, ""));

        // markiere diesen Beitrag als beobachtet
        ziel = viaXpath0("ancestor::TD[1]/preceding-sibling::TD[4]", ziel);
        // nicht nur ein Feld in dem Wrapper setzen, sondern bis ins unterliegende HTML-Element durchschreiben
        ziel.setAttribute("threadId", threadId);
        ziel.setAttribute("lastDate", lastDate.getTime());
        ziel.setAttribute("beitraege", beitraege);
        if (beitraege > beitraege_pro_seite) {
            ziel.getElementsByTagName("A")[0].href += "?goto=new";
        }
    }
    // Gesamtzahl-Threads und Beiträge
    var header = viaXpath0("//TABLE//TABLE/TBODY/TR/TD/FONT/B[contains(text(), 'Beiträge')]");
    header.innerHTML += "<BR>" + row.snapshotLength + " / " + anzahlBeitraege;
    header = viaXpath0("//TABLE//TABLE/TBODY/TR/TD/FONT/B[contains(text(), 'Gelesen')]");
    header.innerHTML += "<BR>" + anzahlLesungen;
    colorizeBoardOverview();
}


function formatProfile(beitragCounterFontElement, username) {
    username = username.data.trim();
    document.title = "KG-" + username + " (Profil)";   // Benutzername

    // Nur für Staffs: Link zum Löschen des Avatar-Bildchens
    var text = viaXpath0("HTML/BODY/TABLE//B[FONT/A/U[contains(text(), 'Live Diskutieren')]]/../FONT/B");
    if (text.innerHTML.indexOf(" (Staff-Member)") > 0) {
        text = "/cache/b_5/avatare/tn_";
        var img = viaXpath0("//TABLE//TABLE//TABLE/TBODY/TR/TD/IMG[@border='0' and contains(@src, '" + text + "')]");
        if (img) {
            text = img.src.substr(img.src.indexOf(text) + text.lastIndexOf("tn_"));
            img = viaXpath0("following-sibling::FONT/STRONG", img);
            img.innerHTML =
                "<A href='/?forumid=5&action=delavatar&avatar=forum&file=" + text + "'>Avatar '" + text + "' löschen!</A><BR><BR>"
                + img.innerHTML;
        }
    }

    // biete großen Button zur Suche nach allen Beiträgen an (mehr / ältere Suchergebnisse durch höheres Limit)
    var form = document.createElement("FORM");
    form.action = "/?action=search&forumid=5";
    form.method = "POST";

// ?searchstring=Suchbegriff&searchuser=private_lock&exactname=1&searchmode=keywords&searcharea=all&searchdate=0&zeitdirection=juenger&sortierung=datum&searchdirection=desc&submit=Suchen+starten
    var strings = new Array(
        new Array("searchstring", "Suchbegriff"),
        new Array("searchuser", username),
        new Array("exactname", "1"),
        new Array("searchmode", "keywords"),
        new Array("searcharea", "all"),
        new Array("searchdate", "0"),
        new Array("zeitdirection", "juenger"),
        new Array("sortierung", "datum"),
        new Array("searchdirection", "desc"),
        new Array("submit", "Alle Beitr\u00E4ge von " + username)
    );
    for (var k = 0; k < strings.length; k++) {
        var hiddenInput = document.createElement("INPUT");
        hiddenInput.type = "hidden";
        hiddenInput.name = strings[k][0];
        hiddenInput.value = strings[k][1];
        form.appendChild(hiddenInput);
    }
    form.lastChild.type = "submit";
    form.lastChild.title = "Ausführliche Suche, die weiter in die Vergangenheit reicht";

    beitragCounterFontElement.removeChild(beitragCounterFontElement.firstChild);
    beitragCounterFontElement.appendChild(form);

    var schnellsuche = viaXpath0("//FONT[@size]/STRONG/A[contains(@href, 'searchpost')]");
    schnellsuche.title = "Schnellsuche nach den aktuellsten Beitr\u00E4gen";
    schnellsuche.innerHTML = schnellsuche.innerHTML.replace("Alle Nachrichten", "Aktuelle Beitr&auml;ge");

    var lineHead = viaXpath("//TABLE//TABLE//TABLE//TABLE/TBODY/TR/TD/FONT[@size and @face]");
    var reg_url = /https?:\/\//i;
    for (var k = 0; k < lineHead.snapshotLength; k++) {
        var line = lineHead.snapshotItem(k);
        var value = line.innerHTML.trim();
        // alert(k + "/" + lineHead.snapshotLength + " = '" + value + "'");
        if (value == "Mitglied seit" || value == "Zuletzt online") {
            // annotiere Wochentage an den Daten
            line = viaXpath0("parent::TD/following-sibling::TD/B/FONT[@size and @face]", line);
            value = line.innerHTML;
            var date = parseDate(value);
            if (date) {
                line.innerHTML = wochentag[date.getDay()] + formatDate(date);
            }
        } else if (value == "Webseite") {
            // wandle die Webseite in eine anklickbare Verknüpfung
            line = viaXpath0("parent::TD/following-sibling::TD/B/FONT[@size and @face]", line);
            value = line.innerHTML.trim();
            if (value.length === 0 || "http://www.-".indexOf(value) >= 0) {
                continue;
            }
            if (value.search(reg_url) !== 0) {
                value = "http://" + value;
            }
            line.innerHTML = "<A href=\"" + value + "\">" + line.innerHTML + "</A>";
        }
    }
}


function addGotoForSuchergebnis(term) {
    // vermeide überflüssige Leerzeichen in den Suchbegriffen
    if (term) {
        term = encodeURI(term.replace(/User:/, "").replace(reg_multiSpace, " ").trim());
    }

    // modifiziere die Verknüpfungen, so dass sie direkt zum Ziel führen
    var hl = "highlight=";
    var link = viaXpath("//TR/TD[3]/FONT[@size]/A[contains(@href, '" + hl + "')]");
    for (var i = link.snapshotLength - 1; 0 <= i; i--) {
        var l = link.snapshotItem(i);
        var timestamp = viaXpath("ancestor::TD[1]/following-sibling::TD[@align='center']/FONT[@size]", l, true);
        var von = timestamp.snapshotItem(0).innerHTML;
        timestamp = timestamp.snapshotItem(1);
        var attr = l.href;
        if (term) {
            attr = attr.substr(0, attr.indexOf(hl) + hl.length) + term;
        }
        l.href = attr + "&goto=" + encodeURI(timestamp.innerHTML.replace(reg_space, "_") + "-" + von);

        // annotiere den Wochentag im Suchergebnis
        von = parseDate(timestamp.innerHTML);
        timestamp.innerHTML = wochentag[von.getDay()].substr(1,2) + " " + formatDate(von);
    }

    markThreadLinks(viaXpath0("//TABLE//TABLE[TBODY//B[text()='Thema']]"));
}


// Parameter-Links, die in ein Brett springen, funktionieren nur schlecht -> ersetze den ganzen Rattenschwanz
function fixJumpMenu(threadTitle) {
    var jumpurl = viaXpath0("//SELECT[@name='jumpurl']");
    if (!jumpurl) {
        return;
    }
//     jumpurl.setAttribute("onchange", jumpurl.getAttribute("onchange").replace(/Go/, "window.location.href = "))

    // Die IDs der Hierarchie-Stufen sind nicht in der Seite enthalten -> hart kodiert
    var codes = [
        [2427, "General Board"],
        [2434, "Keuschhaltung in Perfektion"],
        [2437, "Medizinische Fesseln"],
        [2430, "SM-Boards"],
        [2433, "Fetische"],
        [2436, "Hier gibt es alle Stories"],
        [2428, "Wandgeflüster"],
        [2432, "Sklavenstall"],
        [2435, "KG-Träger Boards"],
        [2426, "Key-Holder Boards"],
        [2429, "Staff-Board (Zugriff nur für Forum-Supporter!!)"],
    ], j = 0;
    var reg_uebersicht = /^-+(.*?)-+$/,
        reg_boardId = /.*boardid=(\d+).*/;

    // Aktuellen Eintrag ausgrauen
    var currentBoardID = reg_boardId.exec(window.location), repair = false;
    if (currentBoardID) {
        currentBoardID = currentBoardID[1];
    }
    if (!currentBoardID) {
        var reg_boardId2 = /(threads|display|reply|post)_5_(\d+)/;
        reg_boardId2.exec(window.location);
        currentBoardID = RegExp.$2;
        if (!currentBoardID) {
            // z.B. wenn gerade ein neuer Beitrag geschrieben wurde
            var reply = viaXpath0("//IMG[contains(@src,'/images/theme1/reply.gif')]/parent::A[@href]");
            if (reply) {
                repair = true;
            } else {
                // oder ein Thread gelockt wurde
                reply = viaXpath0("//IMG[contains(@src,'/images/theme1/new_thread.gif')]/parent::A[@href]");
            }
            reg_boardId2.exec(reply.href);
            currentBoardID = RegExp.$2;
        }
    }

    for (var i = 0; i < jumpurl.childNodes.length; i++) {
        var option = jumpurl.childNodes[i];
        // Alle Übersichtseinträge mit -- bekommen die ID des aktuellen Brettes. (macht keinen Sinn, is aber so!)
        if (option.firstChild.data.match(reg_uebersicht)) {
            // Baue URLs auf die Hierarchie-Stufen: z.B. http://kgforum.org/?action=cat&forumid=5&cat=2427
            while (j < codes.length && codes[j][1] != RegExp.$1) {
                // springe über für den jeweiligen Benutzer nicht sichtbare Bereiche hinweg
                j++;
            }
            // console.log(format("reg='%s' j='%s' = '%s' followed by '%s'", RegExp.$1, j, codes[j], codes[j+1]));
            option.value = "/?action=cat&forumid=5&cat=" + codes[j++][0];
            if (codes.length == j) {
                // nur Staffs und Moderatoren können die Moderatoren-Ecke zugreifen
                var mod = document.createElement("OPTION");
                mod.value = "/boardid=2439";
                mod.innerHTML = "&gt;Moderatoren-Ecke";
                jumpurl.insertBefore(mod, jumpurl.childNodes[++i]);
                option = mod;
            } else {
                continue;
            }
        } else if ("Hauptansicht" == option.firstChild.data) {
            option.value = "/"; // relativ zur Domain ... bleibt nur die Domain übrig
            continue;
        }
        // relative URL auf der aktuellen Domain - löscht bestehende Parameter (z.B. nach Absenden eines Beitrages)
        option.value = option.value.replace(reg_boardId, "/threads_5_$1.html");
        if (RegExp.$1 == currentBoardID) {
            option.style.fontWeight = "bold";
            if (repair) {
                repair = option.firstChild.data.substr(1);
                viaXpath0("//IMG[contains(@src,'/images/theme1/folder-2p.gif')]/following-sibling::A[@href]")
                    .innerHTML = repair;
                document.title = "KG-" + threadTitle + " (" + repair + ")";
                repair = null;
            }
        }
    }
}


async function formatMainPage() {
    // Letzter Beitrag je Brett
    var cell = viaXpath("//BODY//TABLE//TABLE//TD/TABLE//TD[@rowspan=2]/parent::TR/parent::TBODY/parent::TABLE/parent::TD");
    var now = new Date();
    for (var i = 0; i < cell.snapshotLength; i++) {
        var c = cell.snapshotItem(i);
        var link = viaXpath0("./TABLE//FONT/parent::A[contains(@href, 'display')]", c);

        var newStamp = parseDate(link.firstChild.firstChild.data);
        link.firstChild.firstChild.data = wochentag[newStamp.getDay()] + formatDate(newStamp);

        var threadId, beitragId;
        if (reg_beitrag.exec(link)) {
            threadId  = parseInt(RegExp.$3),
            beitragId = parseInt(RegExp.$5);
        } else if (/&threadid=(\d+)\|(\d+)/.exec(link)) {
            threadId  = parseInt(RegExp.$1),
            beitragId = parseInt(RegExp.$2);
        }
        var entry = await getThreadEntry(threadId);
        if (entry) {
            if (beitragId == entry.postId) {
                c.title = format("Letzter Beitrag wurde bereits gelesen!%nNichts neues seit:%s", formatDateDifference(newStamp, now));
                c.style.background = lightCyan;
            } else {
                var stamp = new Date(entry.lastDate);
                if (stamp < newStamp) {
                    c.title = format("Neue Beiträge!\nGelesen bis:%s", formatDateDifference(stamp, newStamp));
                    c.style.background = lightGreen;
                } else {
                    c.title = format("Beitrag verschoben oder gelöscht!\nGelesen bis:%s", formatDateDifference(stamp, newStamp));
                    c.style.background = lightYellow;
                }
            }
        }
    }

    // Die letzten 40 Beiträge
    cell = viaXpath("//BODY//TABLE//TABLE//TD[@colspan=5]//B/A[contains(@href, '#')]");
    for (var i = 0; i < cell.snapshotLength; i++) {
        var c = cell.snapshotItem(i);

        var klammer = viaXpath0("parent::B/following-sibling::FONT", c);
        var newStamp = klammer.firstChild.data;
        newStamp = parseDate(newStamp.substr(1, newStamp.length - 2));
        klammer.style.color = "#777";
        klammer.innerHTML = "(" + formatDateDifference(newStamp, now) + ")";

        reg_beitrag.exec(c);
        var threadId  = parseInt(RegExp.$3),
            beitragId = parseInt(RegExp.$5);
        var entry = await getThreadEntry(threadId);
        if (entry) {
            var stamp = new Date(entry.lastDate);
            if (beitragId <= entry.postId) {
                // ein Thread kann mehrfach auftauchen und noch ältere Beiträge anbieten
                c.title = format("Dieser Beitrag wurde bereits gelesen!%nNichts neues seit:%s", formatDateDifference(stamp, now));
                c.style.background = lightCyan;
            } else {
                c.title = format("Neuer Beitrag\nGelesen bis:%s", formatDateDifference(stamp, newStamp));
                c.style.background = lightGreen;
            }
        }
    }
}


document.title = "KG:Forum";
var main = viaXpath0("//BODY//TABLE//TABLE//FONT/B/IMG[@border=0 and contains(@src, '/images/theme1/open.gif')]");
if (main) {
    formatMainPage();

    logTime();
    return;
}


tagArray = document.getElementsByTagName("FONT");
var reg_beitGesch    = /Beitr.{1,2}ge geschrieben/,
    reg_moderator    = /\s*\(Moderator/,
    reg_suchergebnis = /^Suchergebnis: (.*)/,
    reg_start        = /start=(\d+)/,
    reg_threadID     = /threadid=(\d+)/,
    reg_highlight    = /(\?highlight=[^&]+)/;
var board = null;
EACHFONT:
for (i = 0; i < tagArray.length && i < 15; i++) {
    // suche nach Profilen
    if (i > 1 && (board = tagArray[i].firstChild) && board.nodeName == "#text"
        && reg_beitGesch.exec(board.data)) {

        board = tagArray[i - 2].firstChild;
        if (!board || board.nodeName != "#text") {
            continue EACHFONT;
        }

        formatProfile(tagArray[i], board);
        break EACHFONT;
    }

    // suche nach der 2. & 3. Ebene einer Ordnerstruktur, um den Titel von Brett & Thema zu erkennen
    var imgArray = tagArray[i].getElementsByTagName("IMG");
    for (j = 0; j < imgArray.length; j++) {
        var imgSrc = imgArray[j].src;
        if (!imgSrc) {
            continue;
        }

        if (imgSrc.indexOf("folder-2.gif") > 0) {
            board = imgArray[j].nextSibling;
            if (board.nextSibling.nodeName == "FONT") {
                board = board.nextSibling.firstChild;
                if (board.nodeName == "B") {
                    board = board.firstChild;
                }
            }

            fixJumpMenu("");
            formatBoardOverview(board.data.trim());
            break EACHFONT;
        }

        if (imgSrc.indexOf("folder-2p.gif") > 0) {
            board = imgArray[j].parentNode.getElementsByTagName("A");
            if (!board || board.length < 2) {
                continue;
            }
            board = board[1].firstChild;
            if (!board) {
                continue;
            }
            if (board.nodeName == "FONT") {
                board = board.firstChild;
            }
            if (board.nodeName == "B") {
                board = board.firstChild;
            }
            board = board.data.trim();
            var index = board.search(reg_moderator);
            if (index > 0) {
                board = board.substr(0, index);
            }
            document.title = "KG-" + board;
            continue;
        }

        if (imgSrc.indexOf("folder-3.gif") > 0) {
            var threadId = null,
                searchstring = imgArray[j].nextSibling.data.trim();
            var count = 0, maxCount = null;

            if (searchstring.length === 0 && imgArray[j].nextSibling.nextSibling) {
                // eventuell muss noch ein FONT-tag ausgepackt werden.
                searchstring = imgArray[j].nextSibling.nextSibling.firstChild.data.trim();
            }
            document.title = "KG-" + searchstring + " (" + board + ")";
            if (searchstring.search(reg_suchergebnis) === 0) {
                addGotoForSuchergebnis(RegExp.$1);
                break EACHFONT;
            }

            fixJumpMenu(searchstring);
            showDayOfWeek();

            if (reg_beitrag.exec(window.location.pathname)) {
                threadId     = parseInt(RegExp.$3);
                if (RegExp.$7) {
                    maxCount = parseInt(RegExp.$5);
                    count    = parseInt(RegExp.$7);
                }
            }
            if (reg_start.exec(window.location.search)) {
                count    = parseInt(RegExp.$1);
            }
            if (reg_threadID.exec(window.location.search)) {
                threadId = parseInt(RegExp.$1);
            }

            searchstring = reg_highlight.exec(window.location.search) ? RegExp.$1 : null;
            if (searchstring || !threadId || !maxCount || count === 0) {
                // durchsuche die Navigation, falls vorhanden
                tagArray = viaXpath(
                    "//IMG[contains(@src, '/images/theme1/end.gif')]/ancestor::TABLE[@cellspacing=2 and @cellpadding=0]//A[@href]");
                for (i = 0; i < tagArray.snapshotLength; i++) {
                    var link = tagArray.snapshotItem(i);
                    if (reg_beitrag.exec(link.href)) {
                        if (searchstring) {
                            // erhalte die Hervorhebungen der Suche
                            link.href += searchstring;
                        }
                        if (!threadId) {
                            threadId = parseInt(RegExp.$3);
                        }
                        if (RegExp.$7) {
                            if (!maxCount) {
                                maxCount = parseInt(RegExp.$5);
                            }
                            if (count === 0 && link.innerHTML.indexOf("«") >= 0) {
                                count = parseInt(RegExp.$7) + beitraege_pro_seite;
                            }
                        }
                    }
                }
            }

            if (beitraege_pro_seite < maxCount) {
                document.title = document.title.replace(" (", " [" + (1 + count / beitraege_pro_seite | 0) + "] (");
            }
            threadId = insertLinks(threadId, count, maxCount, searchstring);

            if (threadId) {
                threadContentTouchup(threadId, count, maxCount);
            }
            break EACHFONT;
        }
    }
}
// Durchlauf ohne Fehler bis hier her -> dann noch die Zeit loggen und fertig
logTime();

// die anonyme Funktion sofort aufrufen
})();