Kemono zip download

Download kemono post in a zip file

Från och med 2025-03-25. Se den senaste versionen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name               Kemono zip download
// @name:zh-CN         Kemono 下载为ZIP文件
// @namespace          https://greasyfork.org/users/667968-pyudng
// @version            0.13.1
// @description        Download kemono post in a zip file
// @description:zh-CN  下载Kemono的内容为ZIP压缩文档
// @author             PY-DNG
// @license            MIT
// @match              http*://*.kemono.su/*
// @match              http*://*.kemono.party/*
// @require            data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B
// @require            https://update.greasyfork.org/scripts/456034/1558509/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require            https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js
// @resource           vue-js      https://unpkg.com/[email protected]/dist/vue.global.prod.js
// @resource           quasar-icon https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons
// @resource           quasar-css  https://cdn.jsdelivr.net/npm/[email protected]/dist/quasar.prod.css
// @resource           quasar-js   https://cdn.jsdelivr.net/npm/[email protected]/dist/quasar.umd.prod.js
// @connect            kemono.su
// @connect            kemono.party
// @icon               https://kemono.su/favicon.ico
// @grant              GM_xmlhttpRequest
// @grant              GM_registerMenuCommand
// @grant              GM_addElement
// @grant              GM_getResourceText
// @grant              GM_setValue
// @grant              GM_getValue
// @grant              GM_addValueChangeListener
// @run-at             document-start
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* eslint-disable no-return-assign */

/* global LogLevel DoLog Err Assert $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded */
/* global setImmediate JSZip ItemSelector Vue Quasar */

(function __MAIN__() {
    'use strict';

	const CONST = {
		TextAllLang: {
			DEFAULT: 'en-US',
			'zh-CN': {
                DownloadZip: '下载为ZIP文件',
                DownloadPost: '下载当前作品为ZIP文件',
                DownloadCreator: '下载当前创作者为ZIP文件',
                DownloadCustom: '自定义下载',
                Downloading: '正在下载...',
                FetchingCreatorPosts: '正在获取创作者作品列表...',
                SelectAll: '全选',
                TotalProgress: '总进度',
                ProgressBoardTitle: '下载进度',
                Settings: '设置',
                SaveContent: '保存文字内容到html文件',
                SaveApijson: '保存api结果到json文件',
                NoPrefix: '保存文件时不添加文件名前缀',
                FlattenFiles: '保存主文件和附件到同一级文件夹',
                FlattenFilesCaption: '主文件一般是封面图',
                CustomDownload: {
                    PopupTitle: '自定义下载',
                    TypeLabel: '下载...',
                    ServiceLabel: '发布平台',
                    CreatorLabel: '创作者ID',
                    PostLabel: '内容ID',
                    ExtFilterLabel: '只下载以下扩展名的文件',
                    ExtFilterTooltip: '留空以下载所有文件',
                    NoPrefixLabel: '保存文件时不添加文件名前缀',
                    FlattenFilesLabel: '保存主文件和附件到同一级文件夹',
                    FlattenFilesTooltip: '主文件一般是封面图',
                    Type: {
                        Post: '单篇内容',
                        Creator: '创作者的多篇内容'
                    },
                    Cancel: '取消',
                    Download: '开始下载',
                },
                PostsSelector: {
                    Title: '选择内容',
                    OK: '确定',
                    Cancel: '取消',
                    SelectAll: '全选',
                },
            },
            'en-US': {
                DownloadZip: 'Download as ZIP file',
                DownloadPost: 'Download post as ZIP file',
                DownloadCreator: 'Download creator as ZIP file',
                DownloadCustom: 'Custom download',
                Downloading: 'Downloading...',
                FetchingCreatorPosts: 'Fetching creator posts list...',
                SelectAll: 'Select All',
                TotalProgress: 'Total Progress',
                ProgressBoardTitle: 'Download Progress',
                Settings: 'Settings',
                SaveContent: 'Save text content',
                SaveApijson: 'Save api result',
                NoPrefix: 'Do not add filename prefix',
                FlattenFiles: 'Save main file and attachments to same folder',
                FlattenFilesCaption: '"Main file" is usually the cover image',
                CustomDownload: {
                    PopupTitle: 'Custom download',
                    TypeLabel: 'Download ...',
                    ServiceLabel: 'Service Name',
                    CreatorLabel: 'Creator ID',
                    PostLabel: '内容',
                    ExtFilterLabel: 'Download files with these extensions only',
                    ExtFilterTooltip: 'Leave blank to download all files',
                    NoPrefixLabel: 'Do not add filename prefix',
                    FlattenFilesLabel: 'Save main file and attachments to same folder',
                    FlattenFilesTooltip: '"Main file" is usually the cover image',
                    Type: {
                        Post: 'single post',
                        Creator: 'creator posts'
                    },
                    Cancel: 'Cancel',
                    Download: 'Download',
                },
                PostsSelector: {
                    Title: 'Select posts',
                    OK: 'OK',
                    Cancel: 'Cancel',
                    SelectAll: 'Select All',
                },
            }
		}
	};

	// Init language
	const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
	CONST.Text = CONST.TextAllLang[i18n];

    loadFuncs([{
        id: 'utils',
        async func() {
            const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

            function fillNumber(number, length) {
                const str = number.toString();
                return '0'.repeat(length - str.length) + str;
            }

            /**
             * Async task progress manager \
             * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \
             * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)`
             */
            class ProgressManager {
                /** @type {*} */
                info;
                /** @type {number} */
                steps;
                /** @type {progressCallback} */
                callback;
                /** @type {number} */
                finished;
                /** @type {ProgressManager[]} */
                #children;
                /** @type {ProgressManager} */
                #parent;

                /**
                 * This callback is called each time a promise resolves
                 * @callback progressCallback
                 * @param {number} resolved_count
                 * @param {number} total_count
                 * @param {ProgressManager} manager
                 */

                /**
                 * @param {number} [steps=0] - total steps count of the task
                 * @param {progressCallback} [callback] - callback each time progress updates
                 * @param {*} [info] - attach any data about this manager if need
                 */
                constructor(steps=0, callback=function() {}, info=undefined) {
                    this.steps = steps;
                    this.callback = callback;
                    this.info = info;
                    this.finished = 0;

                    this.#children = [];
                    this.#callCallback();
                }

                add() { this.steps++; }

                /**
                 * @template {Promise | null} task
                 * @param {task} [promise] - task to await, null is acceptable if no task to await
                 * @param {number} [finished] - set this.finished to this value, defaults to this.finished+1
                 * @returns {Awaited<task>}
                 */
                async progress(promise, finished) {
                    const val = await Promise.resolve(promise);
                    try {
                        this.finished = typeof finished === 'number' ? finished : this.finished + 1;
                        this.#callCallback();
                        //this.finished === this.steps && this.#parent && this.#parent.progress();
                    } finally {
                        return val;
                    }
                }

                /**
                 * Creates a new ProgressManager as a sub-progress of this
                 * @param {number} [steps=0] - total steps count of the task
                 * @param {progressCallback} [callback] - callback each time progress updates, defaulting to parent.callback
                 * @param {*} [info] - attach any data about the sub-manager if need
                 */
                sub(steps, callback, info) {
                    const manager = new ProgressManager(steps ?? 0, callback ?? this.callback, info);
                    manager.#parent = this;
                    this.#children.push(manager);
                    return manager;
                }

                #callCallback() {
                    this.callback(this.finished, this.steps, this);
                }

                get children() {
                    return [...this.#children];
                }

                get parent() {
                    return this.#parent;
                }

                /**
                 * Resolves after all promise resolved, and callback each time one of them resolves
                 * @param {Array<Promise>} promises
                 * @param {progressCallback} callback
                 */
                static async all(promises, callback) {
                    const manager = new ProgressManager(promises.length, callback);
                    await Promise.all(promises.map(promise => manager.progress(promise, callback)));
                }
            }

            const PerformanceManager = (function() {
                class RunRecord {
                    static #id = 0;

                    /** @typedef {'initialized' | 'running' | 'finished'} run_status */
                    /** @type {number} */
                    id;
                    /** @type {number} */
                    start;
                    /** @type {number} */
                    end;
                    /** @type {number} */
                    duration;
                    /** @type {run_status} */
                    status;
                    /**
                     * Anything for programmers to mark and read, uses as a description for this run
                     * @type {*}
                     */
                    info;

                    /**
                     * @param {*} [info] - Anything for programmers to mark and read, uses as a description for this run
                     */
                    constructor(info) {
                        this.id = RunRecord.#id++;
                        this.status = 'initialized';
                        this.info = info;
                    }

                    run() {
                        const time = performance.now();
                        this.start = time;
                        this.status = 'running';
                        return this;
                    }

                    stop() {
                        const time = performance.now();
                        this.end = time;
                        this.duration = this.end - this.start;
                        this.status = 'finished';
                        return this;
                    }
                }
                class Task {
                    /** @typedef {number | string | symbol} task_id */
                    /** @type {task_id} */
                    id;
                    /** @type {RunRecord[]} */
                    runs;

                    /**
                     * @param {task_id} id
                     */
                    constructor(id) {
                        this.id = id;
                        this.runs = [];
                    }

                    run(info) {
                        const record = new RunRecord(info);
                        record.run();
                        this.runs.push(record);
                        return record;
                    }

                    get time() {
                        return this.runs.reduce((time, record) => {
                            if (record.status === 'finished') {
                                time += record.duration;
                            }
                            return time;
                        }, 0)
                    }
                }
                class PerformanceManager {
                    /** @type {Task[]} */
                    tasks;

                    constructor() {
                        this.tasks = [];
                    }

                    /**
                     * @param {task_id} id
                     * @returns {Task | null}
                     */
                    getTask(id) {
                        return this.tasks.find(task => task.id === id);
                    }

                    /**
                     * Creates a new task
                     * @param {task_id} id
                     * @returns {Task}
                     */
                    newTask(id) {
                        Assert(!this.getTask(id), `given task id ${escJsStr(id)} is already in use`, TypeError);

                        const task = new Task(id);
                        this.tasks.push(task);
                        return task;
                    }

                    /**
                     * Runs a task
                     * @param {task_id} id
                     * @param {*} run_info - Anything for programmers to mark and read, uses as a description for this run
                     * @returns {RunRecord}
                     */
                    run(task_id, run_info) {
                        const task = this.getTask(task_id);
                        Assert(task, `task of id ${escJsStr(task_id)} not found`, TypeError);

                        return task.run(run_info);
                    }

                    totalTime(id) {
                        if (id) {
                            return this.getTask(id).time;
                        } else {
                            return this.tasks.reduce((timetable, task) => {
                                timetable[task.id] = task.time;
                                return timetable;
                            }, {});
                        }
                    }

                    meanTime(id) {
                        if (id) {
                            const task = this.getTask(id);
                            return task.time / task.runs.length;
                        } else {
                            return this.tasks.reduce((timetable, task) => {
                                timetable[task.id] = task.time / task.runs.length;
                                return timetable;
                            }, {});
                        }
                    }
                }

                return PerformanceManager;
            }) ();

            return {
                window: win,
                fillNumber, ProgressManager, PerformanceManager
            }
        }
    }, {
        id: 'dependencies',
        desc: 'load dependencies like vue into the page',
        detectDom: ['head', 'body'],
        async func() {
            const deps = [{
                name: 'vue-js',
                type: 'script',
            }, {
                name: 'quasar-icon',
                type: 'style'
            }, {
                name: 'quasar-css',
                type: 'style'
            }, {
                name: 'quasar-js',
                type: 'script'
            }];

            await Promise.all(deps.map(dep => {
                return new Promise((resolve, reject) => {
                    switch (dep.type) {
                        case 'script': {
                            // Once load, dispatch load event on messager
                            const evt_name = `load:${dep.name};${Date.now()}`;
                            const rand = Math.random().toString();
                            const messager = new EventTarget();
                            const load_code = [
                                '\n;',
                                `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`,
                                `delete window[${escJsStr(rand)}];\n`
                            ].join('\n');
                            unsafeWindow[rand] = messager;
                            $AEL(messager, evt_name, resolve);
                            GM_addElement(document.head, 'script', {
                                textContent: GM_getResourceText(dep.name) + load_code,
                            });
                            break;
                        }
                        case 'style': {
                            GM_addElement(document.head, 'style', {
                                textContent: GM_getResourceText(dep.name),
                            });
                            resolve();
                            break;
                        }
                    }
                });
            }));

            Quasar.setCssVar('primary', 'orange');
            setTimeout(() => Quasar.Dark.set(true));
        }
    }, {
        id: 'api',
        desc: 'api for kemono',
        async func() {
            let api_key = null;

            const Posts = {
                /**
                 * Get a list of creator posts
                 * @param {string} service - The service where the post is located
                 * @param {number | string} creator_id - The ID of the creator
                 * @param {string} [q] - Search query
                 * @param {number} [o] - Result offset, stepping of 50 is enforced
                 */
                posts(service, creator_id, q, o) {
                    const search = {};
                    q && (search.q = q);
                    o && (search.o = o);
                    return callApi({
                        endpoint: `/${service}/user/${creator_id}`,
                        search
                    });
                },

                /**
                 * Get a specific post
                 * @param {string} service
                 * @param {number | string} creator_id
                 * @param {number | string} post_id
                 * @returns {Promise<Object>}
                 */
                post(service, creator_id, post_id) {
                    return callApi({
                        endpoint: `/${service}/user/${creator_id}/post/${post_id}`
                    });
                }
            };
            const Creators = {
                /**
                 * Get a creator
                 * @param {string} service - The service where the creator is located
                 * @param {number | string} creator_id - The ID of the creator
                 * @returns
                 */
                profile(service, creator_id) {
                    return callApi({
                        endpoint: `/${service}/user/${creator_id}/profile`
                    });
                }
            };
            const Custom = {
                /**
                 * Get a list of creator's ALL posts, calling Post.posts for multiple times and joins the results
                 * @param {string} service - The service where the post is located
                 * @param {number | string} creator_id - The ID of the creator
                 * @param {string} [q] - Search query
                 */
                async all_posts(service, creator_id, q) {
                    const posts = [];
                    let offset = 0;
                    let api_result = null;
                    while (!api_result || api_result.length === 50) {
                        api_result = await Posts.posts(service, creator_id, q, offset);
                        posts.push(...api_result);
                        offset += 50;
                    }
                    return posts;
                }
            };
            const API = {
                get key() { return api_key; },
                set key(val) { api_key = val; },
                Posts, Creators,
                Custom,
                callApi
            };
            return API;

            /**
             * callApi detail object
             * @typedef {Object} api_detail
             * @property {string} endpoint - api endpoint
             * @property {Object} [search] - search params
             * @property {string} [method='GET']
             * @property {boolean | string} [auth=false] - whether to use api-key in this request; true for send, false for not, and string for sending a specific key (instead of pre-configured `api_key`); defaults to false
             */

            /**
             * Do basic kemono api request
             * This is the queued version of _callApi
             * @param {api_detail} detail
             * @returns
             */
            function callApi(...args) {
                return queueTask(() => _callApi(...args), 'callApi');
            }

            /**
             * Do basic kemono api request
             * @param {api_detail} detail
             * @returns
             */
            function _callApi(detail) {
                const search_string = new URLSearchParams(detail.search).toString();
                const url = `https://kemono.su/api/v1/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
                const method = detail.method ?? 'GET';
                const auth = detail.auth ?? false;

                return new Promise((resolve, reject) => {
                    auth && api_key === null && reject('api key not found');

                    const options = {
                        method, url,
                        headers: {
                            accept: 'application/json'
                        },
                        onload(e) {
                            try {
                                e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
                            } catch(err) {
                                reject(err);
                            }
                        },
                        onerror: err => reject(err)
                    }
                    if (typeof auth === 'string') {
                        options.headers.Cookie = auth;
                    } else if (auth === true) {
                        options.headers.Cookie = api_key;
                    }
                    GM_xmlhttpRequest(options);
                });
            }
        }
    }, {
        id: 'gui',
        desc: 'reusable GUI components',
        dependencies: 'dependencies',
        async func() {
            class ProgressBoard {
                /** @type {Vue} */
                app;

                /** Vue component instance */
                instance;

                /** @typedef {{ finished: number, total: number }} progress */
                /** @typedef {{ name: string, progress: progress }} item */
                /** @type {item[]} */
                items;

                constructor() {
                    const that = this;
                    this.items = [];
                    
                    // GUI
                    const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
                    const board = $$CrE({
                        tagName: 'div',
                        classes: 'board'
                    });
                    board.innerHTML = `
                        <q-layout view="hhh lpr fff">
                            <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :position="$q.platform.is.mobile ? 'standard' : 'bottom'" seamless :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile">
                                <q-card class="container-card">
                                    <q-card-section class="header row">
                                        <div class="text-h6">${ CONST.Text.ProgressBoardTitle }</div>
                                        <q-space></q-space>
                                        <q-btn :icon="minimized ? 'expand_less' : 'expand_more'" flat round dense @click="minimized = !minimized"></q-btn>
                                        <q-btn icon="close" flat round dense v-close-popup></q-btn>
                                    </q-card-section>
                                    <q-slide-transition>
                                        <q-card-section class="body" v-show="!minimized">
                                            <q-list class="list" :class="{ minimized: minimized }">
                                                <q-item tag="div" v-for="item of items">
                                                    <q-item-section>
                                                        <q-item-label>{{ item.name }}</q-item-label>
                                                    </q-item-section>
                                                    <q-item-section>
                                                        <q-linear-progress animation-speed="500" :indeterminate="item.progress.total === 0" :value="item.progress.total > 0 ? item.progress.finished / item.progress.total : 0" :color="item.progress.total > 0 && item.progress.total === item.progress.finished ? 'green' : 'blue'"></q-linear-progress>
                                                    </q-item-section>
                                                </q-item>
                                            </q-list>
                                        </q-card-section>
                                    </q-slide-transition>
                                </q-card>
                            </q-dialog>
                        </q-layout>
                    `;
                    container.append(board);
                    detectDom('html').then(html => html.append(container));

                    detectDom('head').then(head => addStyle(`
                        :is(.container-card):not(.mobile *) {
                            max-width: 45vw;
                        }
                        .container-card :is(.header, .body):not(.mobile *) {
                            width: 40vw;
                        }
                        .container-card :is(.body .list):not(.mobile *) {
                            height: 40vh;
                            overflow-y: auto;
                        }
                        .container-card :is(.body .list.minimized):not(.mobile *) {
                            overflow-y: hidden;
                        }
                    `, 'progress-board-style'));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                items: that.items,
                                minimized: false,
                                show: false
                            }
                        },
                        methods: {
                            debug() {
                                debugger;
                            }
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);
                    this.app.mount(board);
                }

                /**
                 * update item's progress display on progress_board
                 * @param {string} name - must be unique among all items
                 * @param {progress} progress
                 */
                update(name, progress) {
                    let item = this.instance.items.find(item => item.name === name);
                    if (!item) {
                        item = { name, progress };
                        this.instance.items.push(item);
                    }
                    item.progress = progress;
                }

                /**
                 * remove an item
                 * @param {string} name
                 */
                remove(name) {
                    let item_index = this.instance.items.findIndex(item => item.name === name);
                    if (item_index === -1) { return null; }
                    return this.instance.items.splice(item_index, 1)[0];
                }

                /**
                 * remove all existing items
                 */
                clear() {
                   this.instance.items.splice(0, this.instance.items.length);
                }

                get minimized() {
                    return this.instance.minimized;
                }

                set minimized(val) {
                    this.instance.minimized = !!val;
                }

                get closed() {
                    return !this.instance.show;
                }

                set closed(val) {
                    this.instance.show = !val;
                }
            }

            const board = new ProgressBoard();

            class CustomDownloadPanel {
                app;
                instance;
                #promise; // Promise to be created on show() call, and resolved on user submit
                #resolve; // resolve function for the promise above

                constructor() {
                    const that = this;
                    
                    // GUI
                    const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
                    const panel = $$CrE({
                        tagName: 'div',
                        classes: 'panel'
                    });
                    panel.innerHTML = `
                        <q-layout view="hhh lpr fff">
                            <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile" @hide="cancel">
                                <q-card class="container-card-custom-dl">
                                    <q-card-section class="header row">
                                        <div class="text-h6">${ CONST.Text.CustomDownload.PopupTitle }</div>
                                        <q-space></q-space>
                                        <q-btn icon="close" flat round dense v-close-popup></q-btn>
                                    </q-card-section>
                                    <q-card-section class="body q-pa-md scroll">
                                        <q-list>
                                            <q-item>
                                                <q-item-section>
                                                    <q-select hide-bottom-space filled v-model="download_type" :options="avail_dl_types" label="${ CONST.Text.CustomDownload.TypeLabel }" ref="dltype_ref" :rules="[ val => !!val ]" lazy-rules></q-select>
                                                </q-item-section>
                                            </q-item>
                                            <q-item>
                                                <q-item-section>
                                                    <q-select hide-bottom-space filled v-model="service" :options="avail_services" label="${ CONST.Text.CustomDownload.ServiceLabel }" ref="service_ref" :rules="[ val => !!val ]" lazy-rules></q-select>
                                                </q-item-section>
                                            </q-item>
                                            <q-item>
                                                <q-item-section>
                                                   <q-input hide-bottom-space filled type="number" v-model.number="creator_id" label="${ CONST.Text.CustomDownload.CreatorLabel }" ref="creator_ref" :rules="[ val => !!val ]" lazy-rules></q-input>
                                                </q-item-section>
                                            </q-item>
                                            <q-item v-if="download_type.value === 'post'">
                                                <q-item-section>
                                                    <q-input hide-bottom-space filled type="number" v-model.number="post_id" label="${ CONST.Text.CustomDownload.PostLabel }" ref="post_ref" :rules="[ val => download_type.value !== 'post' || !!val ]" lazy-rules></q-input>
                                                </q-item-section>
                                            </q-item>
                                            <q-item>
                                                <q-item-section>
                                                    <q-input hide-bottom-space filled v-model="ext_filter" label-slot>
                                                        <template v-slot:label>
                                                            <div class="row items-center all-pointer-events">
                                                                ${ CONST.Text.CustomDownload.ExtFilterLabel }
                                                                <q-tooltip class="bg-grey-8" anchor="top left" self="bottom left" :offset="[0, 8]">
                                                                    ${ CONST.Text.CustomDownload.ExtFilterTooltip }
                                                                </q-tooltip>
                                                            </div>
                                                        </template>
                                                    </q-input>
                                                </q-item-section>
                                            </q-item>
                                            <q-item tag="label" clickable>
                                                <q-item-section>
                                                    <q-item-label>${ CONST.Text.CustomDownload.NoPrefixLabel }</q-item-label>
                                                </q-item-section>
                                                <q-item-section side top>
                                                    <q-checkbox left-label v-model="no_prefix"></q-checkbox>
                                                </q-item-section>
                                            </q-item>
                                            <q-item tag="label" clickable>
                                                <q-item-section>
                                                    <q-item-label>${ CONST.Text.CustomDownload.FlattenFilesLabel }</q-item-label>
                                                    <q-item-label caption>${ CONST.Text.CustomDownload.FlattenFilesTooltip }</q-item-label>
                                                </q-item-section>
                                                <q-item-section side top>
                                                    <q-checkbox left-label v-model="flatten_files"></q-checkbox>
                                                </q-item-section>
                                            </q-item>
                                        </q-list>
                                    </q-card-section>
                                    <q-card-actions :align="$q.platform.is.mobile ? 'center' : 'right'">
                                        <q-btn flat v-close-popup>${ CONST.Text.CustomDownload.Cancel }</q-btn>
                                        <q-btn flat @click="submit" :loading="loading" color="primary">${ CONST.Text.CustomDownload.Download }</q-btn>
                                    </q-card-action>
                                </q-card>
                            </q-dialog>
                        </q-layout>
                    `;
                    container.append(panel);
                    detectDom('html').then(html => html.append(container));
                    detectDom('head').then(head => addStyle(`
                        .container-card-custom-dl :is([type=text], input[type=password], input[type=number]) {
                            box-shadow: unset;
                            background: unset;
                        }
                        .container-card-custom-dl :is(.header, .body):not(.mobile *) {
                            width: 30vw;
                            min-width: 175px;
                        }
                        .container-card-custom-dl .body {
                            max-height: 75vh;
                        }
                    `));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                panel: that,
                                show: false,
                                autoclose: true,
                                download_type: null,
                                service: '',
                                creator_id: 0,
                                post_id: 0,
                                ext_filter: '',
                                no_prefix: false,
                                flatten_files: false,
                                loading: false,
                                avail_dl_types: [{
                                    label: CONST.Text.CustomDownload.Type.Post,
                                    value: 'post'
                                }, {
                                    label: CONST.Text.CustomDownload.Type.Creator,
                                    value: 'creator'
                                }],
                                avail_services: [{
                                    "label": "Patreon",
                                    "value": "patreon"
                                }, {
                                    "label": "Pixiv Fanbox",
                                    "value": "fanbox"
                                }, {
                                    "label": "Discord",
                                    "value": "discord"
                                }, {
                                    "label": "Fantia",
                                    "value": "fantia"
                                }, {
                                    "label": "Afdian",
                                    "value": "afdian"
                                }, {
                                    "label": "Boosty",
                                    "value": "boosty"
                                }, {
                                    "label": "Gumroad",
                                    "value": "gumroad"
                                }, {
                                    "label": "SubscribeStar",
                                    "value": "subscribestar"
                                }, {
                                    "label": "DLsite",
                                    "value": "dlsite"
                                }]
                            }
                        },
                        methods: {
                            submit() {
                                if (!this.validate()) { return; }
                                this.show = !this.autoclose;
                                this.resolve({
                                    download_type: this.download_type.value,
                                    service: this.service.value,
                                    creator_id: this.creator_id,
                                    post_id: this.post_id,
                                    ext_filter: this.ext_filter.split(/[, ]+/).map(ext => ext.replace(/^\.+/, '')).filter(ext => ext.length),
                                    no_prefix: this.no_prefix,
                                    flatten_files: this.flatten_files,
                                });
                            },
                            cancel() {
                                // This will also be invoked after download button clicked
                                // Because the dialog popup will be closed after download button clicked
                                // Since only the first call to the resolve function takes effect to the promise
                                // This does not make anything wrong
                                this.resolve(null);
                            },
                            validate() {
                                const refs = ['dltype_ref', 'service_ref', 'creator_ref', 'post_ref'].map(ref_name => this.$refs[ref_name]).filter(ref => !!ref);
                                refs.forEach(ref => ref.validate());
                                return !refs.some(ref => ref.hasError);
                            }
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);
                    this.app.mount(panel);
                }

                get loading() {
                    return this.instance.loading;
                }

                set loading(val) {
                    this.instance.loading = !!val;
                }

                get showing() {
                    return this.instance.show;
                }
                
                /** @typedef {'post' | 'creator'} download_type */
                /** @typedef {'patreon' | 'fanbox' | 'discord' | 'fantia' | 'afdian' | 'boosty' | 'gumroad' | 'subscribestar' | 'dlsite'} kemono_service */
                /**
                 * Display and wait for user submit
                 * @param {Object} defaultValue
                 * @param {download_type} defaultValue.download_type
                 * @param {kemono_service} defaultValue.service
                 * @param {number | string} defaultValue.creator_id
                 * @param {number | string} [defaultValue.post_id]
                 * @returns {Promise<null | { download_type: download_type, service: kemono_service, creator_id: number, post_id?: number, autoclose: boolean }>}
                 */
                show({
                    download_type = 'post', no_prefix = false, flatten_files = false,
                    service = 'patreon', creator_id = null, post_id = null,
                    autoclose = true
                } = {}) {
                    if (this.showing) { return this.#promise; }
                    ({ promise: this.#promise, resolve: this.#resolve } = Promise.withResolvers());
                    this.instance.resolve = this.#resolve;
                    this.instance.download_type = this.instance.avail_dl_types.find(obj => obj.value === download_type);
                    this.instance.service = this.instance.avail_services.find(obj => obj.value === service);
                    this.instance.creator_id = parseInt(creator_id, 10);
                    this.instance.post_id = parseInt(post_id, 10);
                    this.instance.autoclose = autoclose;
                    this.instance.no_prefix = no_prefix;
                    this.instance.flatten_files = flatten_files;
                    this.instance.show = true;

                    return this.#promise;
                }

                close() {
                    this.instance.show = false;
                }
            }

            const panel = new CustomDownloadPanel();

            class PostsSelector {
                app;
                instance;
                posts;
                #promise; // Promise to be created on show() call, and resolved on user submit
                #resolve; // resolve function for the promise above

                /**
                 * @typedef {Object} post
                 * @property {number} id
                 * @property {number} user
                 * @property {string} service
                 * @property {string} title
                 * @property {string} content
                 * @property {Object} embed
                 * @property {boolean} shared_file
                 * @property {string} added
                 * @property {string} published
                 * @property {string} edited
                 * @property {Object} file
                 * @property {Object} attachments
                 * @property {*} poll
                 * @property {*} captions
                 * @property {*} tags
                 */

                constructor() {
                    const that = this;
                    
                    // GUI
                    const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
                    const panel = $$CrE({
                        tagName: 'div',
                        classes: 'panel'
                    });

                    panel.innerHTML = `
                        <q-layout view="hhh lpr fff">
                            <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile" @hide="cancel">
                                <q-card class="container-card-posts-selector">
                                    <q-card-section class="header row">
                                        <div class="text-h6">${ CONST.Text.PostsSelector.Title }</div>
                                        <q-space></q-space>
                                        <q-btn icon="close" flat round dense v-close-popup></q-btn>
                                    </q-card-section>
                                    <q-card-section class="body q-pa-md scroll">
                                        <q-list separator>
                                            <q-item tag="label" clickable v-ripple>
                                                <q-item-section>
                                                    <q-item-label lines="1">
                                                        <div class="text-subtitle1">${ CONST.Text.PostsSelector.SelectAll }</div>
                                                    </q-item-label>
                                                </q-item-section>
                                                <q-item-section side>
                                                    <q-checkbox v-model="select_all" @update:model-value="val => selectAll(val)"></q-checkbox>
                                                </q-item-section>
                                            </q-item>
                                            <q-item v-for="post of posts" tag="label" clickable v-ripple>
                                                <q-item-section avatar>
                                                    <q-avatar square>
                                                        <q-img loading="lazy" ratio="1" fit="cover" :src="post.file.path" v-if="!!post.file?.path"></q-img>
                                                    </q-avatar>
                                                </q-item-section>
                                                <q-item-section>
                                                    <q-item-label lines="1">{{ post.title }}</q-item-label>
                                                </q-item-section>
                                                <q-item-section side>
                                                    <q-checkbox v-model="selected_posts" :val="post" @update:model-value="onSelect"></q-checkbox>
                                                </q-item-section>
                                            </q-item>
                                        </q-list>
                                    </q-card-section>
                                    <q-card-actions :align="$q.platform.is.mobile ? 'center' : 'right'">
                                        <q-btn flat v-close-popup>${ CONST.Text.PostsSelector.Cancel }</q-btn>
                                        <q-btn flat @click="submit" color="primary">${ CONST.Text.PostsSelector.OK }</q-btn>
                                    </q-card-action>
                                </q-card>
                            </q-dialog>
                        </q-layout>
                    `;

                    container.append(panel);
                    detectDom('html').then(html => html.append(container));
                    detectDom('head').then(head => addStyle(`
                        .container-card-posts-selector :is(.header, .body):not(.mobile *) {
                            min-width: 175px;
                        }
                        .container-card-posts-selector .body {
                            max-height: 75vh;
                        }
                    `));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                selector: that,
                                show: false,
                                select_all: false,
                                /** @type {post[]} */
                                posts: [],
                                /** @type {post[]} */
                                selected_posts: [],
                            }
                        },
                        methods: {
                            submit() {
                                if (!this.validate()) { return; }
                                this.show = false;
                                this.resolve(this.selected_posts.map(
                                    post => this.selector.posts.find(p => p.id === post.id)
                                ));
                            },
                            cancel() {
                                // This will also be invoked after submit button clicked
                                // Because the dialog popup will be closed after download button clicked
                                // Since only the first call to the resolve function takes effect to the promise
                                // This does not make anything wrong
                                this.selected_posts.splice(0, this.selected_posts.length);
                                this.resolve(null);
                            },
                            validate() {
                                const refs = [].map(ref_name => this.$refs[ref_name]).filter(ref => !!ref);
                                refs.forEach(ref => ref.validate());
                                return !refs.some(ref => ref.hasError);
                            },
                            selectAll(selected) {
                                if (selected ? this.selected_posts.length === this.posts.length : !this.selected_posts.length) { return; }
                                this.selected_posts.splice(0, this.selected_posts.length);
                                selected && this.selected_posts.push(...this.posts);
                                selected && !this.select_all && (this.select_all = true);
                            },
                            onSelect() {
                                // Sort selected_posts by posts
                                this.selected_posts.sort((p1, p2) => this.posts.indexOf(p2) - this.posts.indexOf(p1));

                                // Update select_all status
                                const select_all = this.selected_posts.length === this.posts.length ? true : (this.selected_posts.length ? null : false);
                                select_all !== this.select_all && (this.select_all = select_all);
                            }
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);
                    this.app.mount(panel);

                }

                /**
                 * 
                 * @param {Object} detail
                 * @param {post[]} detail.posts
                 * @param {post[]} [detail.select_all] - whether all posts to be selected by default, true if omitted
                 * @returns 
                 */
                show({ posts, select_all = true }) {
                    if (this.showing) { return this.#promise; }
                    ({ promise: this.#promise, resolve: this.#resolve } = Promise.withResolvers());

                    this.posts = posts;
                    this.instance.resolve = this.#resolve;
                    this.instance.posts = posts;
                    this.instance.selectAll(select_all);
                    this.instance.show = true;
                    return this.#promise;
                }
            }

            const selector = new PostsSelector();

            return { ProgressBoard, board, CustomDownloadPanel, panel, PostsSelector, selector };
        }
    }, {
        id: 'downloader',
        desc: 'core zip download utils',
        dependencies: ['utils', 'api', 'settings'],
        async func() {
            const utils = require('utils');
            const API = require('api');
            const settings = require('settings');

            // Performance record
            const perfmon = new utils.PerformanceManager();
            perfmon.newTask('fetchPost');
            perfmon.newTask('saveAs');
            perfmon.newTask('_fetchBlob');


            class DownloaderItem {
                /** @typedef {'file' | 'folder'} downloader_item_type */
                /**
                 * Name of the item, CANNOT BE PATH
                 * @type {string}
                 */
                name;
                /** @type {downloader_item_type} */
                type;

                /**
                 * @param {string} name
                 * @param {downloader_item_type} type
                 */
                constructor(name, type) {
                    this.name = name;
                    this.type = type;
                }
            }

            class DownloaderFile extends DownloaderItem{
                /** @type {Blob} */
                data;
                /** @type {Date} */
                date;
                /** @type {string} */
                comment;

                /**
                 * @param {string} name - name only, CANNOT BE PATH
                 * @param {Blob} data
                 * @param {Object} detail
                 * @property {Date} [date]
                 * @property {string} [comment]
                 */
                constructor(name, data, detail) {
                    super(name, 'file');
                    this.data = data;
                    Object.assign(this, detail);
                }

                zip(jszip_instance) {
                    const z = jszip_instance ?? new JSZip();
                    const options = {};
                    this.date && (options.date = this.date);
                    this.comment && (options.comment = this.comment);
                    z.file(this.name, this.data, options);
                    return z;
                }
            }

            class DownloaderFolder extends DownloaderItem {
                /** @type {Array<DownloaderFile | DownloaderFolder>} */
                children;

                /**
                 * @param {string} name - name only, CANNOT BE PATH
                 * @param {Array<DownloaderFile | DownloaderFolder>} [children]
                 */
                constructor(name, children) {
                    super(name, 'folder');
                    this.children = children && children.length ? children : [];
                }

                zip(jszip_instance) {
                    const z = jszip_instance ?? new JSZip();
                    for (const child of this.children) {
                        switch (child.type) {
                            case 'file': {
                                child.zip(z);
                                break;
                            }
                            case 'folder': {
                                const sub_z = z.folder(child.name);
                                child.zip(sub_z);
                            }
                        }
                    }
                    return z;
                }
            }

            /**
             * @typedef {Object} JSZip
             */
            /**
             * @typedef {Object} zipObject
             */

            /**
             * Download one post in zip file
             * @param {Object} detail
             * @param {string} detail.service
             * @param {number | string} detail.creator_id
             * @param {number | string} detail.post_id
             * @param {function} [detail.callback] - called each time made some progress, with two numeric arguments: finished_steps and total_steps
             * @param {boolean} [detail.flatten_files]
             * @param {boolean} [detail.no_prefix]
             * @param {string[]} [detail.ext_filter]
             */
            async function downloadPost({
                service, creator_id, post_id,
                callback = function() {},
                flatten_files, no_prefix, ext_filter
            } = {}) {
                const manager = new utils.ProgressManager(3, callback, {
                    layer: 'root', service, creator_id, post_id
                });
                const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
                const folder = await manager.progress(fetchPost({ api_data: data, manager, flatten_files, no_prefix, ext_filter }));
                const zip = folder.zip();
                const filename = `${data.post.title}.zip`;
                manager.progress(await saveAs(filename, zip, manager));
            }

            /**
             * @typedef {Object} PostInfo
             * @property {string} service,
             * @property {number} creator_id
             * @property {number} post_id
             */

            /**
             * Download one or multiple posts in zip file, one folder for each post
             * @param {Object} detail
             * @param {PostInfo | PostInfo[]} detail.posts
             * @param {string} detail.filename - file name of final zip file to be delivered to the user
             * @param {*} detail.callback - Progress callback, like downloadPost's callback param
             * @param {boolean} [detail.flatten_files]
             * @param {boolean} [detail.no_prefix]
             * @param {string[]} [detail.ext_filter]
             */
            async function downloadPosts({
                posts, filename,
                callback = function() {},
                flatten_files, no_prefix, ext_filter
            } = {}) {
                Array.isArray(posts) || (posts = [posts]);
                const manager = new utils.ProgressManager(posts.length + 1, callback, {
                    layer: 'root', posts, filename
                });

                // Fetch posts
                const post_folders = await Promise.all(
                    posts.map(async post => {
                        const data = await API.Posts.post(post.service, post.creator_id, post.post_id);
                        const folder = await fetchPost({ api_data: data, manager, flatten_files, no_prefix, ext_filter });
                        await manager.progress();
                        return folder;
                    })
                );

                // Merge all post's folders into one
                const folder = new DownloaderFolder(filename);
                post_folders.map(async post_folder => {
                    folder.children.push(post_folder);
                })

                // Convert folder to zip
                const zip = folder.zip();

                // Deliver to user
                await manager.progress(saveAs(filename, zip, manager));
            }

            /**
             * Fetch one post
             * @param {Object} detail
             * @param {Object} detail.api_data - kemono post api returned data object
             * @param {utils.ProgressManager} detail.manager
             * @param {boolean} [detail.flatten_files]
             * @param {boolean} [detail.no_prefix]
             * @param {string[]} [detail.ext_filter]
             * @returns {Promise<DownloaderFolder>}
             */
            async function fetchPost({ api_data, manager, flatten_files, no_prefix, ext_filter }) {
                const perfmon_run = perfmon.run('fetchPost', api_data);
                const sub_manager = manager.sub(0, manager.callback, {
                    layer: 'post', data: api_data
                });

                flatten_files = flatten_files !== undefined ? flatten_files : settings.flatten_files;
                no_prefix = no_prefix !== undefined ? no_prefix : settings.no_prefix;

                const date = new Date(api_data.post.edited);
                const folder = new DownloaderFolder(escapePath(api_data.post.title), [
                    ...settings.save_apijson ? [new DownloaderFile('data.json', JSON.stringify(api_data))] : [],
                    ...settings.save_content ? [new DownloaderFile('content.html', api_data.post.content, { date })] : []
                ]);

                let attachments_folder = folder;
                if (api_data.post.attachments.length && !flatten_files) {
                    attachments_folder = new DownloaderFolder('attachments');
                    folder.children.push(attachments_folder);
                }
                const tasks = [
                    // api_data.post.file
                    (async function(file) {
                        if (!file.path) { return; }
                        const ext = getFileExt(file);
                        if (ext_filter && ext_filter.length && !ext_filter.includes(ext)) { return; }

                        sub_manager.add();
                        const prefix = no_prefix ? '' : 'file-';
                        const ori_name = getFileName(file);
                        const name = escapePath(prefix + ori_name);
                        const url = getFileUrl(file);
                        const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
                        folder.children.push(new DownloaderFile(
                            name, blob, {
                                date,
                                comment: JSON.stringify(file)
                            }
                        ));
                    }) (api_data.post.file),

                    // api_data.post.attachments
                    ...api_data.post.attachments.map(async (attachment, i) => {
                        if (!attachment.path) { return; }
                        const ext = getFileExt(attachment);
                        if (ext_filter && ext_filter.length && !ext_filter.includes(ext)) { return; }
                        
                        sub_manager.add();
                        const prefix = no_prefix ? '' : `${ utils.fillNumber(i+1, api_data.post.attachments.length.toString().length) }-`;
                        const ori_name = getFileName(attachment);
                        const name = escapePath(prefix + ori_name);
                        const url = getFileUrl(attachment);
                        const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
                        attachments_folder.children.push(new DownloaderFile(
                            name, blob, {
                                date,
                                comment: JSON.stringify(attachment)
                            }
                        ));
                    }),
                ];
                await Promise.all(tasks);

                // Make sure sub_manager finishes even when no async tasks
                if (sub_manager.steps === 0) {
                    sub_manager.steps = 1;
                    await sub_manager.progress();
                }

                perfmon_run.stop();

                return folder;

                /**
                 * @typedef {Object} kemono_file
                 * @property {string} path
                 * @property {string} [name]
                 */
                /**
                 * @param {kemono_file} file
                 * @returns {string}
                 */
                function getFileName(file) {
                    return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
                }

                /**
                 * @param {kemono_file} file 
                 * @returns {string}
                 */
                function getFileUrl(file) {
                    return (file.server ?? 'https://n1.kemono.su') + '/data' + file.path;
                }

                /**
                 * @param {kemono_file} file 
                 * @returns {string} file extension name, not including '.'
                 */
                function getFileExt(file) {
                    const name = getFileName(file);
                    return name.slice(name.lastIndexOf('.') + 1);
                }
            }

            /**
             * Deliver zip file to user
             * @param {string} filename - filename (with extension, e.g. "file.zip")
             * @param {JSZip} zip,
             * @param {string | null} comment - zip file comment
             */
            async function saveAs(filename, zip, manager, comment=null) {
                const perfmon_run = perfmon.run('saveAs', filename);
                const sub_manager = manager.sub(100, manager.callback, {
                    layer: 'zip', filename, zip
                });

                const options = { type: 'blob' };
                comment !== null && (options.comment = comment);
                const blob = await zip.generateAsync(options, metadata => sub_manager.progress(null, metadata.percent));
                const url = URL.createObjectURL(blob);
                const a = $$CrE({
                    tagName: 'a',
                    attrs: {
                        download: filename,
                        href: url
                    }
                });
                a.click();

                perfmon_run.stop();
            }

            /**
             * Fetch blob data from given url \
             * Queued function of _fetchBlob
             * @param {string} url
             * @param {utils.ProgressManager} [manager]
             * @returns {Promise<Blob>}
             */
            function fetchBlob(...args) {
                if (!fetchBlob.initialized) {
                    queueTask.fetchBlob = {
                        max: 3,
                        sleep: 0
                    };
                    fetchBlob.initialized = true;
                }

                return queueTask(() => _fetchBlob(...args), 'fetchBlob');
            }

            /**
             * Fetch blob data from given url
             * @param {string} url
             * @param {utils.ProgressManager} [manager]
             * @param {number} [retry=3] - times to retry before throwing an error
             * @returns {Promise<Blob>}
             */
            async function _fetchBlob(url, manager, retry = 3) {
                const perfmon_run = perfmon.run('_fetchBlob', url);
                const sub_manager = manager.sub(0, manager.callback, {
                    layer: 'file', url
                });

                const blob = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET', url,
                        responseType: 'blob',
                        async onprogress(e) {
                            sub_manager.steps = e.total;
                            await sub_manager.progress(null, e.loaded);
                        },
                        onload(e) {
                            e.status === 200 ? resolve(e.response) : onerror(e)
                        },
                        onerror
                    });

                    async function onerror(err) {
                        if (retry) {
                            await sub_manager.progress(null, -1);
                            const result = await _fetchBlob(url, manager, retry - 1);
                            resolve(result);
                        } else {
                            reject(err)
                        }
                    }
                });

                perfmon_run.stop();

                return blob;
            }

            /**
             * Replace unallowed special characters in a path part
             * @param {string} path - a part of path, such as a folder name / file name
             */
            function escapePath(path) {
                // Replace special characters
                const chars_bank = {
                    '\\': '\',
                    '/': '/',
                    ':': ':',
                    '*': '*',
                    '?': '?',
                    '"': "'",
                    '<': '<',
                    '>': '>',
                    '|': '|'
                };
                for (const [char, replacement] of Object.entries(chars_bank)) {
                    path = path.replaceAll(char, replacement);
                }

                // Disallow ending with dots
                path.endsWith('.') && (path += '_');
                return path;
            }

            return {
                downloadPost, downloadPosts,
                fetchPost, saveAs,
                perfmon
            };
        }
    }, {
        id: 'settings',
        detectDom: 'html',
        dependencies: 'dependencies',
        params: ['GM_setValue', 'GM_getValue'],
        async func(GM_setValue, GM_getValue) {
            // settings 数据,所有设置项在这里配置
            /**
             * @typedef {Object} setting
             * @property {string} title - 设置项名称
             * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素
             * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值
             * @property {string} storage - GM存储key
             * @property {*} default - 用户未设置过时的默认值(初始值)
             */
            /** @type {setting[]} */
            const settings_data = [{
                title: CONST.Text.SaveContent,
                caption: 'content.html',
                key: 'save_content',
                storage: 'save_content',
                default: true,
            }, {
                title: CONST.Text.SaveApijson,
                caption: 'data.json',
                key: 'save_apijson',
                storage: 'save_apijson',
                default: true,
            }, {
                title: CONST.Text.NoPrefix,
                key: 'no_prefix',
                storage: 'no_prefix',
                default: false,
            }, {
                title: CONST.Text.FlattenFiles,
                caption: CONST.Text.FlattenFilesCaption,
                key: 'flatten_files',
                storage: 'flatten_files',
                default: false
            }];

            // settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数
            const settings = {};
            settings_data.forEach(setting => {
                Object.defineProperty(settings, setting.key, {
                    get() {
                        return GM_getValue(setting.storage, setting.default);
                    },
                    set(val) {
                        GM_setValue(setting.storage, !!val);
                    }
                });
            });

            // 创建设置界面
            const container = $$CrE({
                tagName: 'div',
                styles: { all: 'initial', position: 'fixed' }
            })
            const app_elm = $CrE('div');
            app_elm.innerHTML = `
                <q-layout view="hhh lpr fff">
                    <q-dialog v-model="show">
                        <q-card>
                            <q-card-section>
                                <div class="text-h6">${ CONST.Text.Settings }</div>
                            </q-card-section>
                            <q-card-section style="max-height: 75vh;" class="scroll">
                                <q-list>
                                    <q-item tag="label" v-for="setting of settings_data" v-ripple>
                                        <q-item-section>
                                            <q-item-label>{{ setting.title }}</q-item-label>
                                            <q-item-label caption v-if="setting.caption">{{ setting.caption }}</q-item-label>
                                        </q-item-section>
                                        <q-item-section avatar>
                                            <q-toggle color="primary" v-model="settings[setting.key]" @update:model-value="val => settings[setting.key] = val"></q-toggle>
                                        </q-item-section>
                                    </q-item>
                                </q-list>
                            </q-card-section>
                        </q-card>
                    </q-dialog>
                </q-layout>
            `;
            container.append(app_elm);
            $('html').append(container);
            const app = Vue.createApp({
                data() {
                    return {
                        show: false,
                        settings_data,
                        settings
                    };
                },
                mounted() {
                    GM_registerMenuCommand(CONST.Text.Settings, e => this.show = true);
                    GM_addValueChangeListener('settings', () => {
                        this.save_content = settings.save_content;
                        this.save_apijson = settings.save_apijson;
                    });
                }
            });
            app.use(Quasar);
            app.mount(app_elm);

            return settings;
        }
    }, {
        id: 'user-interface',
        dependencies: ['utils', 'api', 'downloader', 'gui', 'settings'],
        async func() {
            const utils = require('utils');
            const api = require('api');
            const downloader = require('downloader');
            const settings = require('settings');
            const gui = require('gui');

            let downloading = false;
            let selector = null;

            // Console User Interface
            const ConsoleUI = utils.window.ZIP = {
                version: GM_info.script.version,
                require,

                api, downloader,
                get ui() { return require('user-interface') },

                downloadCurrentPost, downloadCurrentCreator, userDownload, getPageType
            };

            // Menu User Interface
            GM_registerMenuCommand(CONST.Text.DownloadPost, downloadCurrentPost);
            GM_registerMenuCommand(CONST.Text.DownloadCreator, downloadCurrentCreator);
            GM_registerMenuCommand(CONST.Text.DownloadCustom, downloadCustom);

            // Graphical User Interface
            // Make button
            const dlbtn = $$CrE({
                tagName: 'button',
                styles: {
                    'background-color': 'transparent',
                    'color': 'white',
                    'border': 'transparent'
                },
                listeners: [['click', userDownload]]
            });
            const dltext = $$CrE({
                tagName: 'span',
                props: {
                    innerText: CONST.Text.DownloadZip
                }
            });
            const dlprogress = $$CrE({
                tagName: 'span',
                styles: {
                    display: 'none',
                    'margin-left': '10px'
                }
            });
            dlbtn.append(dltext);
            dlbtn.append(dlprogress);

            const board = gui.board;

            // Place button each time a new action panel appears (meaning navigating into a post page)
            let observer;
            detectDom({
                selector: '.post__actions, .user-header__actions',
                callback: action_panel => {
                    // Hide dlprogress, its content is still for previous page
                    dlprogress.style.display = 'none';

                    // Append to action panel
                    action_panel.append(dlbtn);

                    // Disconnect old observer
                    observer?.disconnect();

                    // Observe action panel content change, always put download button in last place
                    observer = detectDom({
                        root: action_panel,
                        selector: 'button',
                        callback: btn => btn !== dlbtn && action_panel.append(dlbtn)
                    });
                }
            });

            return {
                ConsoleUI,
                dlbtn, dltext,
                get ['selector']() { return selector; },
                downloadCurrentPost, downloadCurrentCreator,
                getCurrentPost, getCurrentCreator, getPageType
            }

            function userDownload() {
                const page_type = getPageType();
                const func = ({
                    post: downloadCurrentPost,
                    creator: downloadCurrentCreator
                })[page_type] ?? function() {};
                return func();
            }

            async function downloadCurrentPost() {
                const post_info = getCurrentPost();
                if (downloading) { return; }
                if (!post_info) { return; }

                try {
                    downloading = true;
                    dlprogress.style.display = 'inline';
                    dltext.innerText = CONST.Text.Downloading;
                    board.minimized = false;
                    board.closed = false;
                    board.clear();
                    await downloader.downloadPost({
                        service: post_info.service,
                        creator_id: post_info.creator_id,
                        post_id: post_info.post_id,
                        callback: on_progress
                    });
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }

                function on_progress(finished_steps, total_steps, manager) {
                    const info = manager.info;
                    info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);

                    const progress = { finished: finished_steps, total: total_steps };
                    info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
                    info.layer === 'post' && board.update(info.data.post.title, progress);
                    info.layer === 'file' && board.update(info.url, progress);
                    info.layer === 'zip' && board.update(info.filename, progress);
                    board.closed = false;
                }
            }

            async function downloadCurrentCreator() {
                const creator_info = getCurrentCreator();
                if (downloading) { return; }
                if (!creator_info) { return; }

                try {
                    downloading = true;
                    Quasar.Loading.show({ message: CONST.Text.FetchingCreatorPosts });
                    const [profile, selected_posts] = await Promise.all([
                        api.Creators.profile(creator_info.service, creator_info.creator_id),
                        selectCreatorPosts(creator_info, () => Quasar.Loading.hide())
                    ]);
                    if (selected_posts) {
                        dlprogress.style.display = 'inline';
                        dltext.innerText = CONST.Text.Downloading;
                        board.minimized = false;
                        board.closed = false;
                        board.clear();
                        await downloader.downloadPosts({
                            posts: selected_posts.map(post => ({
                                service: creator_info.service,
                                creator_id: creator_info.creator_id,
                                post_id: post.id
                            })),
                            filename: `${profile.name}.zip`,
                            callback: on_progress
                        });
                    }
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }

                function on_progress(finished_steps, total_steps, manager) {
                    const info = manager.info;
                    info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);

                    const progress = { finished: finished_steps, total: total_steps };
                    info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
                    info.layer === 'post' && board.update(info.data.post.title, progress);
                    info.layer === 'file' && board.update(info.url, progress);
                    info.layer === 'zip' && board.update(info.filename, progress);
                    board.closed = false;
                }
            }

            async function downloadCustom() {
                if (downloading) { return; }

                try {
                    const post_info = getCurrentPost();
                    const creator_info = getCurrentCreator();
                    const info = post_info ?? creator_info ?? {};
                    const dl_info = await gui.panel.show({
                        download_type: post_info ? 'post' : (creator_info ? 'creator' : undefined),
                        service: info.service ?? undefined,
                        creator_id: info.creator_id ?? undefined,
                        post_id: info.post_id ?? undefined,
                        autoclose: false,
                        no_prefix: settings.no_prefix,
                        flatten_files: settings.flatten_files
                    });
                    if (dl_info !== null) {
                        downloading = true;

                        if (dl_info.download_type === 'post') {
                            gui.panel.close();
                            board.minimized = false;
                            board.closed = false;
                            board.clear();
                            await downloader.downloadPost({
                                service: dl_info.service,
                                creator_id: dl_info.creator_id,
                                post_id: dl_info.post_id,
                                no_prefix: dl_info.no_prefix,
                                flatten_files: dl_info.flatten_files,
                                ext_filter: dl_info.ext_filter,
                                callback: on_progress
                            });
                        } else if (dl_info.download_type === 'creator') {
                            gui.panel.loading = true;
                            Assert(creator_info !== null, 'dl_info.download_type === "creator" but creator_info is null', TypeError);
                            const [profile, selected_posts] = await Promise.all([
                                api.Creators.profile(creator_info.service, creator_info.creator_id),
                                selectCreatorPosts(creator_info, () => {
                                    gui.panel.loading = false;
                                    gui.panel.close();
                                })
                            ]);
                            if (selected_posts) {
                                dlprogress.style.display = 'inline';
                                dltext.innerText = CONST.Text.Downloading;
                                board.minimized = false;
                                board.closed = false;
                                board.clear();
                                await downloader.downloadPosts({
                                    posts: selected_posts.map(post => ({
                                        service: creator_info.service,
                                        creator_id: creator_info.creator_id,
                                        post_id: post.id,
                                    })),
                                    no_prefix: dl_info.no_prefix,
                                    flatten_files: dl_info.flatten_files,
                                    ext_filter: dl_info.ext_filter,
                                    filename: `${profile.name}.zip`,
                                    callback: on_progress
                                });
                            }
                        }
                    }
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }

                function on_progress(finished_steps, total_steps, manager) {
                    const info = manager.info;
                    const progress = { finished: finished_steps, total: total_steps };
                    info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
                    info.layer === 'post' && board.update(info.data.post.title, progress);
                    info.layer === 'file' && board.update(info.url, progress);
                    info.layer === 'zip' && board.update(info.filename, progress);
                    board.closed = false;
                }
            }

            /**
             * User select creator's posts
             * @param {CreatorInfo} creator_info
             * @param {function} onAjaxLoad - callback when api ajax loaded
             * @returns 
             */
            async function selectCreatorPosts(creator_info, onAjaxLoad=function() {}) {
                const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
                typeof onAjaxLoad === 'function' && onAjaxLoad();
                const selected_posts = await gui.selector.show({ posts });
                return selected_posts;
            }

            /**
             * User select creator's posts
             * @param {CreatorInfo} creator_info
             * @param {function} onAjaxLoad - callback when api ajax loaded
             * @returns 
             */
            async function _selectCreatorPosts(creator_info, onAjaxLoad=function() {}) {
                const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
                typeof onAjaxLoad === 'function' && onAjaxLoad();
                const selected_posts = await new Promise((resolve, reject) => {
                    if (!selector) {
                        selector = new ItemSelector();
                        selector.setTheme('dark');
                        selector.elements.container.style.setProperty('z-index', '1');
                    }
                    selector.show({
                        text: CONST.Text.SelectAll,
                        children: posts.map(post => ({
                            post,
                            text: post.title
                        }))
                    }, {
                        title: CONST.Text.DownloadCreator,
                        onok(e, json) {
                            const posts = json.children.map(obj => obj.post);
                            resolve(posts);
                        },
                        oncancel: e => resolve(null),
                        onclose: e => resolve(null)
                    });
                });
                return selected_posts;
            }

            /**
             * @typedef {Object} PostInfo
             * @property {string} service,
             * @property {number} creator_id
             * @property {number} post_id
             */
            /**
             * Get post info in current page
             * @returns {PostInfo | null}
             */
            function getCurrentPost() {
                const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/;
                const match = location.pathname.match(regpath);
                if (!match) {
                    return null;
                } else {
                    return {
                        service: match[1],
                        creator_id: parseInt(match[2], 10),
                        post_id: parseInt(match[3], 10)
                    }
                }
            }

            /**
             * @typedef {Object} CreatorInfo
             * @property {string} service
             * @property {number} creator_id
             */
            /**
             * Get creator info in current page
             * @returns {CreatorInfo | null}
             */
            function getCurrentCreator() {
                const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)/;
                const match = location.pathname.match(regpath);
                if (!match) {
                    return null;
                } else {
                    return {
                        service: match[1],
                        creator_id: parseInt(match[2], 10)
                    }
                }
            }

            /** @typedef { 'post' | 'creator' } page_type */
            /**
             * @returns {page_type}
             */
            function getPageType() {
                const matchers = {
                    post: {
                        type: 'regpath',
                        value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/
                    },
                    creator: {
                        type: 'func',
                        value: () => /^\/([a-zA-Z]+)\/user\/(\d+)/.test(location.pathname) && !location.pathname.includes('/post/')
                    },
                }
                for (const [type, matcher] of Object.entries(matchers)) {
                    if (FunctionLoader.testCheckers(matcher)) {
                        return type;
                    }
                }
            }
        },
    }]);
}) ();