Greasy Fork is available in English.

2ch Poster

Перекатчик тредов.

// ==UserScript==
// @name         2ch Poster
// @namespace    http://tampermonkey.net/
// @version      2024-09-30
// @description  Перекатчик тредов.
// @match        https://2ch.hk/*
// @match        https://2ch.life/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        none
// ==/UserScript==

//@ts-check
/**
 * @template {keyof HTMLElementTagNameMap} T
 * @typedef {object} VirtualElement
 * @property {T} tagName
 * @property {Partial<Omit<HTMLElementTagNameMap[T], "style">>} attributes
 * @property {Partial<CSSStyleDeclaration>} style
 * @property {(VirtualElement<any>|string)[]} children
 */
/**
 * @typedef {object} State
 * @property {number} index
 * @property {any[]} arr
 */
/**
 * @template T
 * @typedef {[function():Readonly<T>, function(Partial<T>):void, Readonly<T>]} UseStateReturnTuple
 */
/**
 * @typedef {object} PostPayload
 * @property {string} board 
 * @property {number|null|undefined} threadNumber 
 * @property {string|null|undefined} subject 
 * @property {string|null|undefined} comment 
 * @property {File[]} files 
 */
/**
 * @typedef {object} ThreadToWatch
 * @property {string} board
 * @property {number} threadNumber
 * @property {number} limit
 * @property {boolean} isLeaveLinkToNewThread
 */
/**
 * @typedef {object} NetworkConfig
 * @property {string} domain
 * @property {number} attempts
 * @property {number} updatingDelayMs
 * @property {number} postingDelayMs
 */
/**
 * @async
 * @callback ConfigFormCallback
 * @param {ThreadToWatch} threadToWatch
 * @param {PostPayload} payload
 * @param {NetworkConfig} networkConfig
 * @returns {Promise<void>}
 */
/**
 * @callback LoggerCallback
 * @param {function(...any):void} logger
 * @param {...any} data
 * @returns {void}
 */
/**
 * @template {keyof HTMLElementTagNameMap} T
 * @callback OnChangeCallback
 * @param {HTMLElementTagNameMap[T]} element
 * @return {void}
 */
/**
 * @template {keyof HTMLElementTagNameMap} T
 * @callback OnClickCallback
 * @param {HTMLElementTagNameMap[T]} element
 * @return {void}
 */
(function () {
    'use strict';
    //--------------------------------------------------------------------------
    /**
     * Virtual DOM rendering tools.
     * Is is not effective, just experimental and proof of concept.
     */
    class MiniReact {
        /**
         * @param {File[]} files
         * @returns {FileList}
         */
        static filesToFileList(files) {
            const transfer = new DataTransfer();
            files.forEach(file => transfer.items.add(file));
            return transfer.files;
        }

        /**
         * @param {File} file
         * @param {string} newName
         * @returns {File}
         */
        static removeFileName(file, newName = "image") {
            const blob = file.slice(0, file.size, file.type);
            return new File([blob], newName, { type: file.type });
        }

        /**
        * @template T
        * @param {function():void} render
        * @param {State} state
        * @param {function(function():void, State, T):UseStateReturnTuple<T>} pureUseState
        * @returns {function(T):UseStateReturnTuple<T>}
        */
        createStateManager(render, state, pureUseState) {
            return (initialValue) => pureUseState(render, state, initialValue);
        }

        /**
         * Simple state manager.
         * 
         * @template T
         * @param {function():void} render
         * @param {State} state
         * @param {T} initialValue
         * @returns {UseStateReturnTuple<T>}
         */
        pureUseState(render, state, initialValue) {
            const currentIndex = state.index;
            if (!(currentIndex in state.arr)) {
                state.arr[currentIndex] = initialValue;
            }

            ++(state.index);
            return [() => state.arr[currentIndex], (stateUpdate) => {
                if (typeof stateUpdate === 'object' && stateUpdate !== null) {
                    const newState = {};
                    for (const [key, value] of Object.entries(state.arr[currentIndex])) {
                        newState[key] = (Object.hasOwn(stateUpdate, key) ? stateUpdate[key] : value);
                    }
                    state.arr[currentIndex] = newState;
                }
                else {
                    state.arr[currentIndex] = stateUpdate;
                }
                console.debug("New state", state.arr);
                // stateIndex = 0;
                render();
            }, initialValue];
        }

        /**
         * @template {keyof HTMLElementTagNameMap} T
         * @param {VirtualElement<T>} element
         * @returns {HTMLElementTagNameMap[T]|Text}
         */
        mountElementOnce(element) {
            if (element.attributes.id === undefined) {
                throw new Error("Unable to find id of virtual element");
            }
            const old = document.getElementById(element.attributes.id);
            if (old !== null) {
                // @ts-ignore
                return old;
            }
            const domElem = this.#buildVirtualElement(element);
            document.body.prepend(domElem);
            return domElem;
        }

        /**
         * @template {keyof HTMLElementTagNameMap} T
         * @param {Node} old
         * @param {VirtualElement<T>|string} modern
         * @param {number} depth
         * @returns {void}
         */
        renderNode(old, modern, depth = 1) {
            if (!(old instanceof HTMLElement || old instanceof Text)) {
                console.warn("Unsupported type of DOM node:", old.nodeType);
                return;
            }

            if (typeof modern === "string") {
                if (old instanceof Text) {
                    if (old.wholeText !== modern) {
                        this.#replaceNode(old, modern);
                    }
                    return;
                }
                this.#replaceNode(old, modern);
                return;
            }
            if (old instanceof Text) {
                this.#replaceNode(old, modern);
                return;
            }
            if (modern.tagName !== old.tagName.toLowerCase()) {
                this.#replaceNode(old, modern);
                return;
            }
            this.#renderAttributes(old, modern);

            // process children.
            const oldChildren = old.childNodes;
            const modernChildren = modern.children;
            for (let i = oldChildren.length - 1; i >= modernChildren.length; i--) {
                console.debug(`${i}) Removed old child:`, oldChildren[i]);
                old.removeChild(oldChildren[i]);
            }
            for (let i = 0; i < oldChildren.length; i++) {
                this.renderNode(oldChildren[i], modernChildren[i], depth + 1);
            }
            // Length of oldChildren is changing during appending new nodes.
            const countOfNewNodes = modernChildren.length - oldChildren.length;
            const firstNewNodeIndex = oldChildren.length;
            for (let i = firstNewNodeIndex; i < firstNewNodeIndex + countOfNewNodes; i++) {
                console.debug(`${i}) Appended modern child:`, modernChildren[i]);
                old.appendChild(this.#buildVirtualElement(modernChildren[i]));
            }
        }

        /**
         * @template {keyof HTMLElementTagNameMap} T
         * @param {Node} old
         * @param {VirtualElement<T>|string} modern
         * @returns {void}
         */
        #replaceNode(old, modern) {
            old.parentElement.replaceChild(this.#buildVirtualElement(modern), old);
            console.debug("Replaced:", { "old": old }, { "modern": modern });
        }

        /**
         * @param {Partial<CSSStyleDeclaration>} style
         * @param {HTMLElement} element
         * @returns {void}
         */
        #applyStyle(element, style) {
            if (Object.keys(style).length === 0) {
                element.removeAttribute("style");
                return;
            }

            // Now we just reset all style attributes.
            for (const [name, value] of Object.entries(style)) {
                element.style[name] = value;
            }
        }

        /**
         * @template {keyof HTMLElementTagNameMap} T
         * @param {VirtualElement<T>|string} virtual
         * @param {boolean} isRecursive
         * @returns {HTMLElementTagNameMap[T]|Text}
         */
        #buildVirtualElement(virtual, isRecursive = true) {
            if (typeof virtual === "string") {
                return document.createTextNode(virtual);
            }

            const element = document.createElement(virtual.tagName);
            this.#applyStyle(element, virtual.style);
            // Set both attributes and event listeners.
            Object.entries(virtual.attributes).forEach(([name, value]) => element[name] = value);

            if (isRecursive) {
                element.append(...virtual.children.map((child) => this.#buildVirtualElement(child)));
            }
            return element;
        }

        /**
         * @template {keyof HTMLElementTagNameMap} T
         * @param {HTMLElement} old
         * @param {VirtualElement<T>} modern
         * @returns {void}
         */
        #renderAttributes(old, modern) {
            const tempModern = document.createElement(modern.tagName);
            const modernAttributes = Object.entries(modern.attributes);
            modernAttributes.forEach(([name, value]) => tempModern[name] = value);

            this.#applyStyle(old, modern.style);
            for (const [modernName, modernValue] of modernAttributes) {
                const oldValue = old[modernName];
                if (typeof modernValue === "function") {
                    // Just always reset event listeners.
                    old[modernName] = modernValue;
                }
                else if (oldValue === null) {
                    console.debug(`New attr set: ${modern.tagName}.${modernName}`, modernValue);
                    old[modernName] = modernValue;
                    continue;
                }
                else if (!this.#areAttributesEqual(oldValue, modernValue)) {
                    console.debug(`Attr changed: ${modern.tagName}.${modernName}`,
                        { "from": oldValue }, { "to": modernValue });
                    old[modernName] = modernValue;
                    continue;
                }
            }
            for (const oldAttr of old.attributes) {
                if (oldAttr.name !== "style" && tempModern.getAttribute(oldAttr.name) === null) {
                    console.debug(`Old attr removed: ${modern.tagName}`, oldAttr);
                    old.removeAttribute(oldAttr.name);
                }
            }
        }

        /**
         * @param {any} first
         * @param {any} second
         * @returns {boolean}
         */
        #areAttributesEqual(first, second) {
            if (!(first instanceof FileList && second instanceof FileList)) {
                return first === second;
            }
            if (first.length !== second.length) {
                return false;
            }
            let isEqual = true;
            for (let i = 0; i < first.length; i++) {
                if (first.item(i) !== second.item(i)) {
                    isEqual = false;
                }
            }
            return isEqual;
        }
    }

    /**
     * Effective fixed-size array.
     * 
     * @template T
     * @see https://stackoverflow.com/a/48061123/
     * @see https://en.wikipedia.org/wiki/Circular_buffer
     */
    class RingBuffer {
        /**
         * @type {T[]}
         */
        container;

        /**
         * @param {number} size 
         */
        constructor(size) {
            this.container = new Array(size);
            this.offset = 0;
        }

        /**
         * @param {T} value
         * @returns {void}
         */
        add(value) {
            this.container[this.offset++] = value;
            this.offset %= this.container.length;
        }

        /**
         * @param {number} i
         * @returns {T}
         */
        get(i) {
            const lastItemIndex = this.offset - 1;
            return this.container[(this.container.length + lastItemIndex - i) % this.container.length];
        }

        /**
         * @template A
         * @param {function(A,T):A} callback
         * @param {A} initialValue
         * @param {boolean} isSkipNotExisted
         * @returns {A}
         */
        reduceReverse(callback, initialValue, isSkipNotExisted = true) {
            let accumulator = initialValue;
            for (let i = 0; i < this.container.length; i++) {
                if (isSkipNotExisted && this.get(i) === undefined) {
                    continue;
                }
                accumulator = callback(accumulator, this.get(i));
            }
            return accumulator;
        }
    }

    class DvachAPI {
        /**
         * @param {LoggerCallback} logger 
         */
        constructor(logger) {
            this.logMsg = logger;
        }

        /**
         * @param {string} domain
         * @param {string} board 
         * @param {number} threadNumber 
         * @returns {Promise<number>}
         */
        async getPostsCount(domain, board, threadNumber) {
            const response = await fetch(`https://${domain}/api/mobile/v2/info/${board}/${threadNumber}`);
            if (response.ok) {
                const json = await response.json();
                return (json?.thread?.posts ?? 0) + 1; // op-post is not counted by API.
            }
            this.logMsg(console.error, `Ошибка HTTP во время получения количества постов:`, response.status);
            return 0;
        }

        /**
         * @param {PostPayload} payload
         * @param {NetworkConfig} networkConfig
         * @returns {Promise<object>} Thread or post.
         */
        async sendPost(payload, networkConfig) {
            const formData = new FormData();
            formData.append("board", payload.board);
            payload.subject && formData.append("subject", payload.subject);
            payload.comment && formData.append("comment", payload.comment);
            payload.threadNumber && formData.append("thread", payload.threadNumber.toString());
            payload.files.forEach(file => formData.append("file[]", MiniReact.removeFileName(file)));
            const send = async () => {
                const response = await fetch(`https://${networkConfig.domain}/user/posting`, {
                    method: "POST",
                    body: formData,
                });
                if (!response.ok) {
                    throw new Error(`Ошибка HTTP во время отправки поста: ${response.status}`)
                }
                const result = await response.json();
                const [errorCode, errorMsg] = [result?.error?.code ?? 0, result?.error?.message];
                if (errorCode !== 0) {
                    throw new Error(`Ошибка сервера во время отправки поста: ${errorCode}: ${errorMsg}`);
                }
                this.logPost(networkConfig.domain, payload, result);
                return result;
            };

            let lastError = null;
            for (let i = 1; i <= networkConfig.attempts; i++) {
                try {
                    return await send();
                }
                catch (e) {
                    lastError = e;
                    this.logMsg(console.warn, `Попытка ${i}/${networkConfig.attempts}) Не удалось отправить форму!`, e);
                    await this.delay(networkConfig.postingDelayMs);
                }
            }
            throw new Error(`Не удалось отправить форму после всех ${networkConfig.attempts} попыток: ${lastError}`);
        }

        /**
         * @param {ThreadToWatch} threadToWatch
         * @param {PostPayload} payload
         * @param {NetworkConfig} networkConfig
         * @returns {Promise<any>} Thread or post.
         */
        async sendPostAfterLimit(threadToWatch, payload, networkConfig) {
            let postsCount = 0;
            while (threadToWatch.board && postsCount < threadToWatch.limit) {
                postsCount = await this.getPostsCount(networkConfig.domain, threadToWatch.board, threadToWatch.threadNumber);
                this.logMsg(console.info, "Текущее количество постов:", postsCount, "/", threadToWatch.limit);
                await this.delay(networkConfig.updatingDelayMs);
            }
            const post = await this.sendPost(payload, networkConfig);
            if (threadToWatch.isLeaveLinkToNewThread && post?.thread) {
                await this.sendPost({
                    board: threadToWatch.board,
                    threadNumber: threadToWatch.threadNumber,
                    subject: null,
                    comment: `[b]Перекат: >>${post.thread}[/b]\n`.repeat(3),
                    files: [],
                }, networkConfig);
            }
            return post;
        }

        /**
         * @param {number} ms 
         * @returns {Promise<void>}
         */
        async delay(ms) {
            return new Promise(function (resolve) {
                setTimeout(resolve, ms);
            });
        }

        /**
         * @param {string} domain
         * @param {string} board 
         * @param {number} threadNumber 
         * @param {number|null} postNumber 
         * @returns {string}
         */
        getThreadURL(domain, board, threadNumber, postNumber = null) {
            const num = postNumber === null ? '' : `#${postNumber}`;
            return `https://${domain}/${board}/res/${threadNumber}.html${num}`;
        }

        /**
         * @param {string} domain
         * @param {PostPayload} payload
         * @param {any} post 
         */
        logPost(domain, payload, post) {
            if (post?.thread !== undefined) {
                const url = this.getThreadURL(domain, payload.board, post.thread);
                this.logMsg(console.info, "Тред создан:", url, post);
            }
            else if (post?.num !== undefined && payload.threadNumber !== null) {
                const url = this.getThreadURL(domain, payload.board, payload.threadNumber, post.num)
                this.logMsg(console.info, "Пост отправлен:", url, post);
            }
            else {
                this.logMsg(console.warn, "Неизвестный тип поста", post);
            }
        }
    }
    //--------------------------------------------------------------------------
    // Functions.
    /**
     * @param {string} id 
     * @param {(VirtualElement<any>|string)[]} nodes 
     * @returns {VirtualElement<"div">}
     */
    function createContainer(id, ...nodes) {
        return {
            tagName: "div",
            attributes: {
                id: id,
            },
            style: {},
            children: nodes,
        };
    }

    /**
     * @template {keyof HTMLElementTagNameMap} T
     * @param {T} element
     * @param {string} flexDirection
     * @param {string|null|undefined} justifyContent
     * @param {(VirtualElement<any>|string)[]} nodes 
     * @returns {VirtualElement<T>}
     */
    function createFlex(element, flexDirection, justifyContent, ...nodes) {
        return {
            tagName: element,
            attributes: {},
            style: {
                display: "flex",
                flexDirection: flexDirection,
                justifyContent: justifyContent ?? "space-between",
                flexWrap: "wrap",
                gap: "0.3em",
            },
            children: nodes,
        };
    }

    /**
     * @param {string} type 
     * @param {string} name 
     * @param {string} label 
     * @param {string|number|boolean|File[]} value
     * @param {OnChangeCallback<"input">} onChange 
     * @param {string|null|undefined} placeholder
     * @param {Partial<Omit<HTMLElementTagNameMap["input"], "style">>} extraAttrs
     * @returns {VirtualElement<"div">}
     */
    function createInput(type, name, label, value, onChange, placeholder = null, extraAttrs = {}) {
        const id = `poster_${name}`;
        /** @type {VirtualElement<"input">} */
        const input = {
            tagName: "input",
            attributes: {
                id: id,
                type: type,
                name: name,
                title: placeholder ?? label,
                placeholder: placeholder ?? "",
                onchange: (event) => {
                    if (!(event.target instanceof HTMLInputElement)) {
                        throw new Error(`Unable to find an input with id ${id}`);
                    }
                    onChange(event.target);
                },
            },
            style: {},
            children: [],
        };
        if (value) {
            if (typeof value === "boolean") {
                input.attributes.checked = value;
            }
            else if (Array.isArray(value)) {
                input.attributes.files = MiniReact.filesToFileList(value);
            }
            else {
                input.attributes.value = value.toString();
            }
        }
        Object.entries(extraAttrs).forEach(([key, value]) => input.attributes[key] = value);

        /** @type {VirtualElement<"label">} */
        const labelElement = {
            tagName: "label",
            attributes: {
                htmlFor: input.attributes.id
            },
            style: {},
            children: [label],
        };
        return createFlex("div", "column", null, labelElement, input);
    };

    /**
     * @param {string} name 
     * @param {string} placeholder 
     * @param {string} value
     * @param {OnChangeCallback<"textarea">|null} onChange
     * @param {Partial<Omit<HTMLElementTagNameMap["textarea"], "style">>} extraAttrs
     * @param {Partial<CSSStyleDeclaration>} extraStyle
     * @returns {VirtualElement<"textarea">}
     */
    function createTextarea(name, placeholder, value, onChange = null, extraAttrs = {}, extraStyle = {}) {
        let realOnChange = null;
        if (onChange) {
            realOnChange = (event) => {
                if (!(event.target instanceof HTMLTextAreaElement)) {
                    throw new Error(`Unable to find a textarea with name ${name}`);
                }
                onChange(event.target);
            };
        }

        /** @type {VirtualElement<"textarea">} */
        const textarea = {
            tagName: "textarea",
            attributes: {
                name: name,
                placeholder: placeholder,
                title: placeholder,
                value: value,
                onchange: realOnChange,
            },
            style: {
                minHeight: "15em",
                minWidth: "20em",
            },
            children: [],
        };
        Object.entries(extraAttrs).forEach(([key, value]) => textarea.attributes[key] = value);
        Object.entries(extraStyle).forEach(([key, value]) => textarea.style[key] = value);
        return textarea;
    };

    /**
     * @param {string} id 
     * @param {string} value 
     * @param {boolean} isDisabled
     * @param {OnClickCallback<"button">} onClick
     * @returns {VirtualElement<"button">}
     */
    function createButton(id, value, isDisabled, onClick) {
        return {
            tagName: "button",
            attributes: {
                type: "button",
                id: id,
                disabled: isDisabled,
                onclick: (event) => {
                    if (!(event.target instanceof HTMLButtonElement)) {
                        throw new Error(`Unable to find a button with id ${id}`);
                    }
                    onClick(event.target);
                },
            },
            style: {
                paddingTop: "0.5em",
                paddingBottom: "0.5em",
            },
            children: [value],
        };
    }

    /**
     * @param {string} legend 
     * @param {VirtualElement<any>[]} nodes
     * @returns {VirtualElement<"fieldset">}
     */
    function createFieldSet(legend, ...nodes) {
        /** @type {VirtualElement<"legend">} */
        const legendElem = {
            tagName: "legend",
            attributes: {},
            style: {},
            children: [legend],
        };
        return createFlex("fieldset", "column", "flex-start", legendElem, ...nodes);
    }

    /**
     * @param {string} id 
     * @param {boolean} isDisplayed
     * @param {VirtualElement<any>[]} nodes
     * @returns {VirtualElement<"form">}
     */
    function createForm(id, isDisplayed, ...nodes) {
        return {
            tagName: "form",
            attributes: {
                id: id,
            },
            style: {
                display: isDisplayed ? "flex" : "none",
                flexDirection: "row",
                justifyContent: "center",
                flexWrap: "wrap",
                gap: "0.3em",
                backdropFilter: "blur(1em)",
                padding: "0.3em",
                zIndex: "1000",
            },
            children: nodes,
        };
    }

    /**
     * @param {ThreadToWatch} threadToWatch
     * @param {PostPayload} payload
     * @param {NetworkConfig} networkConfig
     * @returns {Promise<void>}
     */
    async function validateInput(threadToWatch, payload, networkConfig) {
        console.info("User input for validation:", threadToWatch, networkConfig, payload);
        if ((threadToWatch.threadNumber ? threadToWatch.threadNumber >= 0 : true)
            && (threadToWatch.limit ? threadToWatch.limit >= 0 : true)
            && networkConfig.attempts > 0
            && networkConfig.postingDelayMs > 0
            && (threadToWatch.threadNumber > 0 ? networkConfig.updatingDelayMs > 0 : true)
            && payload.board
            && (payload.threadNumber ? payload.threadNumber > 0 : true)
            && (payload.subject || payload.comment || payload.files.length > 0)
        ) {
            return;
        }
        throw new Error("Введены неверные данные.");
    }

    /**
     * @param {number} maxSize
     * @param {function(string):void} containerUpdater
     * @returns {LoggerCallback}
     */
    function createLogger(maxSize, containerUpdater) {
        /**
         * @type {RingBuffer<string>}
         */
        const buffer = new RingBuffer(maxSize);
        return function (logger, ...data) {
            logger(...data);
            const time = (new Date).toLocaleTimeString();
            const stringData = data.map((value) => typeof value === "object"
                ? JSON.stringify(value, Object.getOwnPropertyNames(value))
                : value).join(" ");
            buffer.add(`${time}) ${stringData}`);
            const text = buffer.reduceReverse((accumulator, value) => accumulator += value + "\n", "");
            containerUpdater(text);
        }
    }

    {
        /** @type {State} */
        const state = { "index": 0, "arr": [] };
        const logSize = 100;
        const miniReact = new MiniReact();
        const useState = miniReact.createStateManager(render, state, miniReact.pureUseState);
        //----------------------------------------------------------------------
        const [getLogText, setLogText] = useState("");
        const [getIsFormDisplayed, setIsFormDisplayed] = useState(false);
        const [getStartButtonState, setStartButtonState, startButtonInitialState]
            = useState({ disabled: false, value: "Старт" });
        const [getShowFormButtonText, setShowFormButtonText, showFormButtonInitialText] = useState("Показать форму");
        const [getThreadToWatch, setThreadToWatch] = useState(/** @type {ThreadToWatch} */({
            board: "",
            threadNumber: 0,
            limit: 500,
            isLeaveLinkToNewThread: true,
        }));
        const [getNetworkConfig, setNetworkConfig] = useState(/** @type {NetworkConfig} */({
            domain: "2ch.hk",
            attempts: 120,
            updatingDelayMs: 500,
            postingDelayMs: 10,
        }));
        const [getPayload, setPayload] = useState(/** @type {PostPayload} */({
            board: "",
            threadNumber: null,
            subject: null,
            comment: null,
            files: [],
        }));
        //----------------------------------------------------------------------
        const logMsg = createLogger(logSize, setLogText);
        const api = new DvachAPI(logMsg);

        function render() {
            const showFormButton = createButton("poster_show_form_button", getShowFormButtonText(), false, (button) => {
                if (!getIsFormDisplayed()) {
                    setIsFormDisplayed(true);
                    setShowFormButtonText("Скрыть форму");
                    button.scrollIntoView();
                    return;
                }
                setIsFormDisplayed(false);
                setShowFormButtonText(showFormButtonInitialText);
            });
            const startButton = createButton("poster_start_button",
                getStartButtonState().value,
                getStartButtonState().disabled,
                () => {
                    logMsg(console.info, "Запущено...");
                    setStartButtonState({ disabled: true, value: "Запущено..." });
                    validateInput(getThreadToWatch(), getPayload(), getNetworkConfig())
                        .then(() => api.sendPostAfterLimit(getThreadToWatch(), getPayload(), getNetworkConfig()))
                        .catch(error => logMsg(console.error, error))
                        .finally(() => {
                            setStartButtonState(startButtonInitialState);
                        });
                });

            const msgLeaveBlank = "Оставьте пустым для немедленной публикации";
            const form = createForm("poster_config_form", getIsFormDisplayed(),
                createFieldSet(
                    "Тред для наблюдения",
                    createInput("text", "threadToWatchBoard", "Борда",
                        getThreadToWatch().board,
                        (input) => {
                            setThreadToWatch({ board: input.value });
                            if (!getPayload().board) {
                                setPayload({ board: input.value });
                            }
                        },
                        msgLeaveBlank,
                    ), createInput("number", "threadToWatchNumber", "Номер треда",
                        getThreadToWatch().threadNumber,
                        (input) => setThreadToWatch({ threadNumber: Number(input.value) }),
                        msgLeaveBlank,
                    ), createInput("number", "limit", "Отправить после этого поста",
                        getThreadToWatch().limit,
                        (input) => setThreadToWatch({ limit: Number(input.value) }),
                        msgLeaveBlank,
                    ), createInput("checkbox", "isLeaveLinkToNewThread", "Опубликовать ссылку на новый тред",
                        getThreadToWatch().isLeaveLinkToNewThread,
                        (input) => setThreadToWatch({ isLeaveLinkToNewThread: input.checked }),
                    ),
                ), createFieldSet(
                    "Настройки сети",
                    createInput("text", "domain", "Домен",
                        getNetworkConfig().domain,
                        (input) => setNetworkConfig({ domain: input.value }),
                    ), createInput("number", "attempts", "Число попыток публикации",
                        getNetworkConfig().attempts,
                        (input) => setNetworkConfig({ attempts: Number(input.value) }),
                    ), createInput("number", "updatingDelayMs", "Пауза между обновлениями треда, мс",
                        getNetworkConfig().updatingDelayMs,
                        (input) => setNetworkConfig({ updatingDelayMs: Number(input.value) }),
                    ), createInput("number", "postingDelayMs", "Пауза между попытками публикации, мс",
                        getNetworkConfig().postingDelayMs,
                        (input) => setNetworkConfig({ postingDelayMs: Number(input.value) }),
                    ),
                ), createFieldSet(
                    "Пост",
                    createInput("text", "payloadBoard", "Борда",
                        getPayload().board,
                        (input) => setPayload({ board: input.value }),
                    ), createInput("number", "payloadThreadNumber", "Номер треда",
                        getPayload().threadNumber ?? "",
                        (input) => setPayload({ threadNumber: Number(input.value) }),
                        "Оставьте пустым для создания нового",
                    ), createInput("text", "subject", "Тема",
                        getPayload().subject ?? "",
                        (input) => setPayload({ subject: input.value }),
                    ), createTextarea("comment", "Комментарий",
                        getPayload().comment ?? "",
                        (textarea) => setPayload({ comment: textarea.value }),
                    ), createInput("file", "file[]", "Файлы",
                        getPayload().files,
                        (input) => setPayload({ files: Array.from(input.files ?? []) }),
                        null, { multiple: true },
                    ),
                    startButton,
                ), createFieldSet(
                    "Лог",
                    createTextarea("poster_log", "Лог", getLogText(),
                        (textarea) => setLogText(textarea.value),
                        { disabled: true },
                        { height: "100%" },
                    ),
                ));

            const app = createContainer("poster_app",
                showFormButton,
                form,
            );
            miniReact.renderNode(
                miniReact.mountElementOnce(createContainer(app.attributes.id)),
                app,
            );
        };

        // initial render
        render();
    }
})();