Greasy Fork is available in English.

轻小说文库+ (重制)

轻小说文库+的重置,写好了就作为一个更新直接发上去,所以到时候记得改name、namespace和描述

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

// ==UserScript==
// @name               轻小说文库+ (重制)
// @namespace          Wenku8+_re
// @version            0.1.4
// @description        轻小说文库+的重置,写好了就作为一个更新直接发上去,所以到时候记得改name、namespace和描述
// @author             PY-DNG
// @license            GPL-3.0-or-later
// @match              http*://www.wenku8.net/*
// @match              http*://www.wenku8.cc/*
// @match              http*://wenku8.net/*
// @match              http*://wenku8.cc/*
// @connect            wenku8.com
// @connect            wenku8.net
// @connect            wenku8.cc
// @connect            p.sda1.dev
// @connect            image.sogou.com
// @connect            *
// @require            https://update.greasyfork.org/scripts/456034/1303041/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://greasyfork.org/scripts/449583-configmanager/code/script.js?version=1326639
// @require            https://greasyfork.org/scripts/471280-url-encoder/code/URL%20Encoder.js?version=1247074
// @require            https://greasyfork.org/scripts/460385-gm-web-hooks/code/script.js?version=1221394
// @require            https://greasyfork.org/scripts/445697-greasy-fork-api/code/Greasy%20Fork%20API.js?version=1055543
// @require            https://fastly.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js
// @require            https://fastly.jsdelivr.net/npm/tippy.js@6.3.7/dist/tippy.umd.min.js
// @require            https://fastly.jsdelivr.net/npm/alertifyjs@1.13.1/build/alertify.min.js
// @require            https://fastly.jsdelivr.net/npm/@shopify/draggable@1.0.0-beta.8/lib/sortable.js
// @require            https://fastly.jsdelivr.net/gh/vkiryukhin/vkBeautify@0a238953acf12ac2f366fd63998514e1ac9db042/vkbeautify.js
// @require            https://fastly.jsdelivr.net/npm/darkmode-js@1.5.7/lib/darkmode-js.min.js
// @require            https://fastly.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.min.js
// @resource           tippy-css       https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/dist/tippy.css
// @resource           alertify-css    https://fastly.jsdelivr.net/npm/alertifyjs@1.13.1/build/css/alertify.min.css
// @resource           alertify-theme  https://fastly.jsdelivr.net/npm/alertifyjs@1.13.1/build/css/themes/default.min.css
// @icon               data:image/vnd.microsoft.icon;base64,AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAD/igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//rlB//3HaP/858P//OfD//znw//858P//OfD//znw//858P//OfD//zitv/91Y///dWP//3Vj//9y3X//cdo//3HaP/9x2j//cdo//zitv/858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//znw//858P//OfD//znw//9x2j//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6cN//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//3ZnP//ogD//6IA//+iAP//ogD//sJb//3HaP/83qn//OfD//zeqf//pw3//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+rGv/90IL//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//cdo//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//OfD//znw//858P//OfD//znw//858P//6IA//+iAP/84rb//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//rQ0//60NP//ogD//6IA//+iAP//ogD//6IA//+iAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//3Vj//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//6wJ///ogD//6sa//3HaP/92Zz//OfD//znw//858P//OfD//znw//858P//OfD//+iAP//ogD//rQ0//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//zitv/+vk7//6IA//+iAP//ogD//6IA//+iAP/+tDT//OfD//znw//858P//OfD//znw//858P//rAn//+iAP//ogD//ct1//3HaP/+tDT//rQ0//6+Tv/858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//ct1//7CW///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw///ogD//6IA//65Qf//ogD//6IA//3HaP/9x2j//6cN//+iAP//ogD//r5O//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//60NP/9x2j//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//+iAP//ogD//OfD//65Qf//ogD//6cN//znw//858P//6IA//+iAP/92Zz//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//6IA//+iAP/858P//cdo//+iAP//ogD//dWP//3Vj///pw3//6IA//7CW//858P//rQ0//+iAP/92Zz//OfD//3Ldf/+tDT//OK2//znw//858P//N6p//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//7CW///ogD//6IA//6+Tv//ogD//6IA//6+Tv/91Y///6IA//+iAP/+vk7//OfD//+iAP//ogD//6IA//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//dWP//+iAP//ogD//6IA//+iAP//ogD//OK2//+rGv//ogD//6IA//3ZnP/858P//6IA//+iAP//qxr//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//sJb//+iAP//pw3//rQ0//+iAP//ogD//OfD//znw//+wlv//6IA//+iAP//ogD//6cN//3ZnP/+vk7//6IA//+iAP/90IL//OfD//60NP//ogD//6IA//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//84rb//rAn//+iAP//ogD//6IA//+iAP/+sCf//rQ0//+iAP//ogD//6IA//+iAP/9x2j//OfD//65Qf//ogD//rAn//znw//858P//rQ0//+iAP/+uUH//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//r5O//60NP//ogD//6IA//+iAP//ogD//6IA//+iAP/9x2j//6IA//+iAP/9x2j//OfD//6+Tv//ogD//6IA//znw//84rb//6IA//+iAP/84rb//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//+nDf//ogD//6cN//60NP/+vk7//ct1//znw//+wlv//6IA//+iAP/858P//OK2//+iAP//ogD//dmc//znw///pw3//6IA//6wJ//83qn//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//cdo//+iAP//ogD//dmc//znw//858P//OfD//znw///pw3//6IA//+nDf/83qn//dmc//+nDf//ogD//6sa//znw//92Zz//6IA//+iAP/858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//N6p//3HaP/+sCf//6IA//+iAP//ogD//6IA//+iAP//qxr//dmc//3ZnP//pw3//6IA//6+Tv/858P//dmc//+iAP//ogD//OfD//znw//9x2j//sJb//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP/+vk7//OfD//znw//91Y///OK2//znw//858P//dWP//3Vj//9x2j//rlB//+nDf//pw3//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//3Ldf//ogD//6IA//+iAP//ogD//6IA//60NP/+tDT//cdo//zitv/+tDT//rQ0//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP/83qn//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//dmc//+iAP/+sCf//OfD//znw//858P//dWP//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//cdo//zeqf/858P//OfD//znw//858P//rQ0//60NP/+tDT//rQ0//60NP/9x2j//cdo//3HaP/91Y///dWP//zeqf/858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
// @grant              GM_getValue
// @grant              GM_setValue
// @grant              GM_listValues
// @grant              GM_deleteValue
// @grant              GM_xmlhttpRequest
// @grant              GM_getResourceText
// @run-at             document-start
// ==/UserScript==

/* global require FuncInfo isWenkuFunction Messager FL_listFunctions FL_getFunction FL_enableFunction FL_disableFunction FL_loadSetting FL_getDebug FL_exportConfig FL_importConfig FL_postMessage FL_recieveMessage */
/* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager */
/* global ConfigManager $URL GMXHRHook GMDLHook GreasyFork */
/* global tippy alertify Sortable vkbeautify Darkmode CryptoJS*/

/* 开发进度
[x]: 已完成
[-]: 正在施工
[?]: 等待测试
[o]: 待完善
[ ]: 待开发
[D]: 暂时搁置,后期计划开发
*: 代表相对于旧版有bug修复/优化/新功能

[x] 功能加载器
  [x] 加载功能
  [x] 启用/禁用功能
  [x] 功能存储隔离
  [x] 账号存储隔离
  [o] 功能日志记录
[o] 设置界面*
  [o] 功能设置
    [x] 独立功能管理界面
      [x] 界面框架
      [x] 启用/停用
      [o] 存储管理
        [x] 查看存储
        [x] 修改存储
        [x] 导出存储
        [x] 导入存储
        [x] 恢复出厂设置
    [o] 功能设置界面框架
      [x] 功能设置界面
      [x] 功能设置界面表格对齐
  [x] 脚本设置
    [x] 设置界面
    [x] 检查更新
    [x] 自动检查更新
    [x] 导出配置
    [x] 导入配置
    [x] 导出日志
	[ ] 跨账号配置迁移
  [x] 防止设置界面同时打开多次
[x] wenku8.cc域名支持
[o] 在线阅读解锁
  [ ] 兼容haoa的轻小说文库下载
[x] 下载解锁
  [x] 下载panel
  [x] 下载页面*
  [x] “本书公告”
  [x] 繁体版页面支持
  [x] 不用iframe
[x] 下载增强*
  [x] txt*
  [x] txtfull*
  [x] jar*
  [x] umd*
  [x] 繁体版页面支持
  [x] 修复文库下载带html字符编码问题*
    [x] txt分卷
    [x] txt全本
[x] 全部插图下载
[ ] 多格式下载器
  [ ] 下载器框架
  [ ] txt全本
  [ ] txt分卷
  [ ] epub全本
  [ ] epub分卷
  [ ] 插图
[o] 单章节下载*
  [x] 文本下载
  [o] 图片下载
    [D] 下载失败时,点击最终提示信息框,可显示详细下载状态信息
  [x] 繁体版页面支持
[x] 书架增强
  [x] 书架整合
  [x] 书架重命名
  [x] 自动推书界面整合
    [x] 自动推书界面
    [x] 自动推书功能未启用时不显示其界面
    [x] 提示框优化
      [x] 实时显示自动推书总次数
      [x] 相同书的提示框合并显示
[o] 自动推书
    [o] 点击显示推书详情
[o] 页面美化*
  [x] 通用页面美化*
    [x] 背景图
    [x] 半透明遮罩层
    [x] 遮罩层高斯模糊
    [x] 兼容深色模式,深色模式和浅色模式分别存储两套遮罩颜色、模糊程度、不透明度
  [o] 书评页面美化
    [x] 书评页面内容宽度平齐*
    [D] 书评页面美化后保留美化前scroll状态
  [o] 阅读页美化*
    [x] 阅读页美化正文居中(可选)*
    [x] 双击alertify和SidePanel不再自动滚屏*
    [D] 阅读页美化正文宽度可调*
  [x] 繁体版页面支持
  [D] 页面内调节面板,实时应用修改
[x] 阅读增强
  [x] 更多字体颜色
  [x] 更多字体大小
  [x] 更多字体
  [x] 繁体版页面支持
  [x] 翻页键支持
[x] 去广告
  [x] 繁体版页面支持
[x] 未登录自动跳转首页
[o] 图床*
  [o] 图床加载框架
    [x] 基础框架
    [x] 图床自动选择机制
      [x] 上传时评分排序
      [x] 上传后记录结果
    [x] 图床返回url自动encodeURI
    [D] 用户手动选择图床机制
  [x] 设置图片上传组件
    [x] 选择图片
    [x] 上传图片
    [x] 清空图片
  [o] 图床
    [x] 搜狗图床
    [D] 360图床
    [x] 流浪图床
[x] 稍后再读*
  [x] 首页稍后再读书籍列表*
  [x] 书介绍页加入/移出按钮
  [x] 用户手动顺序调整*
  [x] 繁体版页面支持
  [x] 首页点击X移除稍后再读*
[x] 书籍信息复制
[x] 书籍tag跳转
[o] 侧边栏
  [x] 侧边栏框架
  [o] 侧边栏默认svg*
    [D] svg一直展示到fa显示出来再隐藏
[x] 深色模式*
  [x] 主页
  [x] 书架
  [x] 书页
  [x] 阅读
  [x] 目录
  [x] 评论
    [x] bbcode
  [x] 评论编辑
  [x] 评论列表
  [x] 排行榜
  [x] tags
  [x] 书评吐槽
  [x] 用户页面
    [x] 用户主页
    [x] 用户面板
    [x] 用户编辑
    [x] 修改密码
    [x] 设置头像
    [x] 用户好友
    [x] 用户链接
    [x] 短消息
      [x] 收件箱
      [x] 发件箱
      [x] 写新消息
  [x] 登录页面
  [x] 跳转api页面
  [x] 设置界面
  [x] UBBEditor menu
  [x] 右上角提示框
  [x] 文库dialog弹窗
  [x] 其他页面
    [x] https://www.wenku8.net/other/jubao.php
    [x] https://www.wenku8.net/other/help.php
  [x] tippy适配
  [x] 页面美化适配
[D] 在线阅读历史记录
[o] 账号切换
  [x] 顶栏账号切换
  [x] 账号自动录入
  [x] 账号管理
[o] 书评草稿
  [x] 书评草稿自动保存和加载
  [x] 书评草稿管理
[o] 书评增强
  [x] 引用
    [x] 引用
    [x] 仅引用楼号
  [D] @
  [x] 链接图片增强*
    [x] 支持https链接
    [x] 自动encodeuri
    [x] 格式检查
  [x] 本地图片插入
    [x] 界面点击上传
    [x] 拖动上传
    [x] 粘贴上传
    [x] 插入图片保持光标位置*
  [x] 自适应高度的编辑器*
  [x] 书评回复标题
  [o] 书评下载保存
    [x] 基本书评下载
    [x] BBCode书评下载
    [x] json格式导出*
    [D] 保存书评为带图片的格式*
  [o] 书评收藏*
    [x] 收藏/取消收藏
    [o] 首页展示
      [o] 防止文本溢出
    [x] 拖动调整顺序
    [ ] “更多”按钮和管理界面
    [ ] 备注
    [x] 默认收藏:文库导航姬
    [ ] 默认收藏:脚本反馈贴
  [x] 换页不刷新*
  [x] 页面内书评编辑
    [x] UBBEditor编辑器支持*
    [x] 应用UBBEditor增强功能*
  [x] 链接跳转
  [D] 旧版页面[img]*.(jpg|png)[/img]加载*
  [x] 繁体版页面支持
  [x] 自动刷新*
  [x] 页面回复后仍在当前页面*
    [x] 后台提交回复并根据文库返回页面热更新当前页面
      [x] 返回没有重定向的错误页面,显示错误信息并保留编辑器内容
      [x] 返回带重定向的书评编辑成功页面,更新页面内容到当前page
      [x] 返回书评第一页,更新页面内容到last page
  [x] Ctrl+Enter提交表单
    [x] Ctrl+Enter提交表单
    [x] Command+Enter提交表单
    [x] 去除Windows+Enter提交表单
    [x] 按钮提示文本
  [x] 表单格式检查
    [x] 不要空回复
    [x] 内容长度≥7
[ ] tag搜索
[x] 脚本执行时间提前
[-] X浏览器支持
[ ] 旧版配置自动导入
[ ] 回调代码Promise化
*/

(function __MAIN__() {
	'use strict';

	// Constances
	const CONST = {
		TextAllLang: {
			DEFAULT: 'zh-CN',
			'zh-CN': {
				Loader: {
					CheckerError: 'Checker Error',
					CheckerInvalid: 'Checker Invalid'
				}
			}
		},
		Config_Ruleset: {
			'version-key': 'config-version',
			'ignores': ["LOCAL-CDN"],
			'defaultValues': {
				//'config-key': {},
				funcs: {},
				debug: []
			},
			'updaters': {
				/*'config-key': [
					function() {
						// This function contains updater for config['config-key'] from v0 to v1
					},
					function() {
						// This function contains updater for config['config-key'] from v1 to v2
					}
				]*/
			},
			'globalUpdaters': [
				function(config) {
					//config
				}
			]
		}
	};

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

	// Functions
	const Functions = [
		// Test
		{
			name: '测试函数',
			description: '用于调试功能加载器以及脚本环境是否正常',
			id: 'test',
			system: false,
			data: {
				load: false,
				storage: false,
				manager: false,
				makeerror: false,
				errortype: 'setTimeout',
				log: [],
				allowdebug: unsafeWindow.isPY_DNG && unsafeWindow.userscriptDebugging,
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				}
			},
			checker: {
				type: 'switch',
				value: true
			},
			func: function() {
				//['message', 'success', 'warning', 'error'].forEach(type => alertify.notify(type, type, 0)); // alertify notifier test

				// Function load
				if (FuncInfo.data.load) {
					DoLog(LogLevel.Success, '测试函数启动了!');
				}

				// Basic storage
				if (FuncInfo.data.storage) {
					GM_setValue('test', true);
					DoLog(`读取test存储项的结果为${GM_getValue('test', 'default text')}`);
					DoLog(`读取unset存储项的结果为${GM_getValue('unset', 'default text')}`);
				}

				// Config Manager
				if (FuncInfo.data.manager) {
					const CM = new ConfigManager(FuncInfo.data.Config_Ruleset);
					const CONFIG = CM.Config;
					GM_setValue('testobj', {prop: 'value'});
					DoLog(`Object.keys(CONFIG).length = ${Object.keys(CONFIG).length}`);
					DoLog(`Object.keys(CONFIG.testobj) = ${Object.keys(CONFIG.testobj)}`);
				}

				// Error dealing
				if (FuncInfo.data.makeerror) {
					switch (FuncInfo.data.errortype) {
						case 'error':
							Err('An error occured!');
							break;
						case 'setTimeout':
							setTimeout(function() {Err('An error occured!')}, 0);
							break;
						case 'setTimeout_arrow':
							setTimeout(() => Err('An error occured!'), 0);
							break;
					}
				}
				if (FuncInfo.data.log && FuncInfo.data.log.length) {
					FuncInfo.data.log.forEach(v => console.log(v));
				}

				// Debug object
				if (FuncInfo.data.allowdebug) {
					Object.defineProperty(unsafeWindow, 'wd', {
						get: function() {
							// Leave a reference here so debugger a will be able to access them
							[require, FuncInfo, isWenkuFunction, Messager, FL_listFunctions, FL_getFunction, FL_enableFunction, FL_disableFunction, FL_loadSetting, FL_postMessage, FL_recieveMessage];
							[LogLevel, DoLog, Err, $, $All, $CrE, $AEL, $$CrE, addStyle, detectDom, destroyEvent, copyProp, copyProps, parseArgs, escJsStr, replaceText, getUrlArgv, dl_browser, dl_GM, AsyncManager];
							[ConfigManager, $URL, GreasyFork];
							[tippy, alertify, Sortable, vkbeautify, Darkmode, CryptoJS];

							debugger;
							return 'wenku8+ debugger: just hit "wd" and return';
						},
						configurable: true,
						enumerable: false,
					});
				}

				return {
					log() {DoLog('Debug function log')},
					number: 1,
					boolean: true,
					'null': null,
					'NaN': NaN,
					'undefined': undefined,
					arr: [0, 1, 2],
					obj: {}
				}
			}
		},

		// Common Style
		{
			name: 'CommonStyle',
			description: '基本组件 - 样式管理器',
			id: 'CommonStyle',
			system: true,
			checker: {
				type: 'switch',
				value: false
			},
			func: function() {
				const Assets = {
					ClassName: {
						Button: 'plus_button',
						Text: 'plus_text',
						TextLight: 'plus_text_light',
						Disabled: 'plus_disabled'
					},
					Color: {
						Text: 'rgb(30, 100, 220)',
						TextLight: 'rgb(70, 150, 230)',
						Button: 'rgb(0, 160, 0)',
						ButtonHover: 'rgb(0, 100, 0)',
						ButtonFocus: 'color: rgb(0, 100, 0)',
						ButtonDisabled: 'rgba(150, 150, 150)',
					}
				};
				// Add assets style
				Assets.ElmStyle = addStyle(
					`.${Assets.ClassName.Text} {color: ${Assets.Color.Text} !important;}` +
					`.${Assets.ClassName.TextLight} {color: ${Assets.Color.TextLight} !important;}` +
					`.${Assets.ClassName.Button} {color: ${Assets.Color.Button} !important; fill: ${Assets.Color.Button} !important; cursor: pointer !important; user-select: none;}` +
					`.${Assets.ClassName.Button}:hover {color: ${Assets.Color.ButtonHover} !important; fill: ${Assets.Color.ButtonHover} !important;}` +
					`.${Assets.ClassName.Button}:focus {${Assets.Color.ButtonFocus} !important; fill: ${Assets.Color.ButtonFocus} !important;}` +
					`.${Assets.ClassName.Button}.${Assets.ClassName.Disabled} {color: ${Assets.Color.ButtonDisabled} !important; fill: ${Assets.Color.ButtonDisabled} !important; cursor: not-allowed !important;}` +
					`.tippy-box[data-theme~="wenku_tip"] {background-color: #f0f7ff;color: black;border: 1px solid #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="top"]>.tippy-arrow::before {border-top-color: #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="left"]>.tippy-arrow::before {border-left-color: #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="right"]>.tippy-arrow::before {border-right-color: #a3bee8;}.tippy-box[data-theme~="wenku_tip"][data-placement^="bottom"]>.tippy-arrow::before {border-bottom-color: #a3bee8;}`
					, 'plus-commonstyle-assets'
				);
				// Add common style
				addStyle(`#dialog{z-index: 1600 !important;} #mask{z-index: 1500 !important;}`, 'plus-commonstyle-style');
				return Assets;
			}
		},

		// FontAwesome
		{
			name: 'FontAwesome',
			description: '基本组件 - FontAwesome(图标)',
			id: 'FontAwesome',
			system: true,
			func: function() {
				const urls = [
					'https://fastly.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css',
					'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
					'https://bowercdn.net/c/Font-Awesome-6.4.0/css/all.min.css',
					'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css',
				];
				const timeout = 15 * 1000;

				let i = 0, loaded = false, tid;
				const link = $$CrE({
					tagName: 'link',
					props: {
						href: urls[i],
						rel: 'stylesheet'
					},
					listeners: [
						['error', changeSource],
						['load', onload]
					]
				});
				tid = setTimeout(changeSource, timeout);
				document.head.appendChild(link);

				return {link, get loaded() {return loaded;}}

				function changeSource() {
					if (++i < urls.length) {
						clearTimeout(tid);
						tid = setTimeout(changeSource, timeout);
						link.href = urls[i];
					} else {
						Err('FontAwesome loading error');
					}
				}

				function onload(e) {
					clearTimeout(tid);
					loaded = true;
					FL_postMessage('FontAwesomeLoaded');
				}
			}
		},

		// Alertify
		{
			name: 'alertify',
			description: '基本组件 - alertify(对话框/信息栏)',
			id: 'alertify',
			system: true,
			func: function() {
				addStyle(GM_getResourceText('alertify-css'), 'plus-alertify-css');
				addStyle(GM_getResourceText('alertify-theme'), 'plus-alertify-theme');
				alertify.set('notifier','position', 'top-right');
			}
		},

		// Tippy
		{
			name: 'tippy',
			description: '基本组件 - tippy(浮动提示框)',
			id: 'tippy',
			system: true,
			func: function() {
				addStyle(GM_getResourceText('tippy-css'), 'plus-tippy-css');
			}
		},

		// Utils
		{
			name: '常用函数库',
			description: '脚本常用函数库',
			id: 'utils',
			system: true,
			checker: {
				type: 'switch',
				value: false
			},
			func: function() {
				return {getLang, getDocument, parseDocument, downloadText, setPageUrl, htmlEncode, htmlDecode, detectDom, randstr, randint, insertText, openDialog, refreshPage, testChecker, submitForm, formEncode, loadFuncs, encrypt, decrypt, getOS, getTime, deepClone};

				// Get language: 0 for simplyfied chinese and others, 1 for traditional chinese
				function getLang() {
					const match = document.cookie.match(/(; *)?jieqiUserCharset=(.+?)( *;|$)/);
					const nvgtLang = ({'zh-CN': 0, 'zh-TW': 1})[navigator.language] || 0;
					return match && match[2] ? (match[2].toLowerCase() === 'big5' ? 1 : 0) : nvgtLang;
				}

				// Download and parse a url page into a html document(dom).
				// when xhr onload: callback.apply(null, [dom, ...args])
				// Usage: getDocument({url, callback[, onerror][, args]}) | getDocument(url, callback) | getDocument(url, callback, args) | getDocument(url, callback, onerror, args)
				function getDocument() {
					const [url, callback, onerror, args] = parseArgs([...arguments], [
						function(args, defaultValues) {
							const arg = args[0];
							return ['url', 'callback', 'onerror', 'args'].map((prop, i) => arg.hasOwnProperty(prop) ? arg[prop] : defaultValues[i]);
						},
						[1, 2],
						[1, 2, 4],
						[1, 2, 3, 4]
					], ['', function() {}, logError, []]);

					GM_xmlhttpRequest({
						method       : 'GET',
						url          : url,
						responseType : 'blob',
						timeout      : 15 * 1000,
						onload       : function(response) {
							const htmlblob = response.response;
							parseDocument(htmlblob, callback, args);
						},
						onerror      : onerror,
						ontimeout    : onerror
					});

					function logError(e) {
						DoLog(LogLevel.Error, 'getDocument: Request Error');
						DoLog(LogLevel.Error, e, 'error');
					}
				}

				function parseDocument(htmlblob, callback, args=[]) {
					const reader = new FileReader();
					reader.onload = function(e) {
						const htmlText = reader.result;
						const dom = new DOMParser().parseFromString(htmlText, 'text/html');
						args = [dom].concat(args);
						callback.apply(null, args);
						//callback(dom, htmlText);
					}
					const charset = ['GBK', 'BIG5'][getLang()];
					reader.readAsText(htmlblob, charset);
				}

				// Save text to textfile
				function downloadText(text, name) {
					if (!text || !name) {return false;};

					// Get blob url
					const blob = new Blob([text],{type:"text/plain;charset=utf-8"});
					const url = URL.createObjectURL(blob);

					// Create <a> and download
					const a = $CrE('a');
					a.href = url;
					a.download = name;
					a.click();
				}

				// Change location.href without reloading using history.pushState/replaceState
				// Usage: setPageUrl(url) | setPageUrl(win, url) | setPageUrl(win, url, push)
				function setPageUrl() {
					const [win, url, push] = parseArgs([...arguments], [
						[2],
						[1,2],
						[1,2,3]
					], [window, '', false]);
					return win.history[push ? 'pushState' : 'replaceState']({modified: true, ...history.state}, '', url);
				}

				// Encode text into html text format
				function htmlEncode(text) {
					const span = $CrE('div');
					span.innerText = text;
					return span.innerHTML;
				}

				// Decode html format text into pure text
				function htmlDecode(text) {
					const span = $CrE('div');
					span.innerHTML = text;
					return span.innerText;
				}

				// Returns a random string
				function randstr(length=16, cases=true, aviod=[]) {
					const all = 'abcdefghijklmnopqrstuvwxyz0123456789' + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : '');
					while (true) {
						let str = '';
						for (let i = 0; i < length; i++) {
							str += all.charAt(randint(0, all.length-1));
						}
						if (!aviod.includes(str)) {return str;};
					}
				}

				function randint(min, max) {
					return Math.floor(Math.random() * (max - min + 1)) + min;
				}

				// Insert into <textarea>
				function insertText(textarea, text, focus=true) {
					const pre_text = textarea.value.substring(0, textarea.selectionStart);
					const after_text = textarea.value.substring(textarea.selectionEnd);
					const curPos = {
						start: {
							atBegin: textarea.selectionStart === 0,
							atEnd: textarea.selectionStart === textarea.value.length,
							atNewLine: /[\r\n]/.test(textarea.value.charAt(textarea.selectionStart-1)), // this excludes the first line
							atWhitespace: /\t/.test(textarea.value.charAt(textarea.selectionStart-1))
						},
						end: {
							atBegin: textarea.selectionEnd === 0,
							atEnd: textarea.selectionEnd === textarea.value.length,
							atNewLine: /[\r\n]/.test(textarea.value.charAt(textarea.selectionEnd)),
						},
					};
					const textWithWhitespace = `${(curPos.start.atBegin || curPos.start.atNewLine || curPos.start.atWhitespace) ? '' : ' '}${text}${((curPos.start.atBegin || curPos.start.atNewLine || curPos.start.atWhitespace) && text.length === 0) ? '' : ' '}`;
					const position = textarea.selectionStart + textWithWhitespace.length;
					textarea.value = `${pre_text}${textWithWhitespace}${after_text}`;

					// Set selection position
					setTimeout(e => {
						const [scrollTop, scrollLeft] = [textarea.scrollTop, textarea.scrollLeft];
						focus && textarea.scrollIntoView();
						focus && textarea.focus();
						textarea.setSelectionRange(position, position);
						[textarea.scrollTop, textarea.scrollLeft] = [scrollTop, scrollLeft]
					}, 0);
				}

				// My openDialog, modified from original wenku8's openDialog, with onload and mask support, and NO CACHE unless showing images
				function openDialog(url, onload){
					!document.getElementById("mask") && unsafeWindow.showMask();
					const [dialogs, Ajax, displayDialog] = [unsafeWindow.dialogs, unsafeWindow.Ajax, unsafeWindow.displayDialog];
					if(url.match(/\.(gif|jpg|jpeg|png|bmp)$/ig)){
						dialogs[url]='<img src="'+url+'" class="imgdialog" onclick="closeDialog()" style="cursor:pointer" />';
						displayDialog(dialogs[url]);
					}else{
						Ajax.Request(url,{onLoading:function(){dialogs[url]=this.response; displayDialog('Loading...');}, onComplete:function(){dialogs[url]=this.response; displayDialog(this.response);typeof onload === 'function' && onload();}});
					}
				}

				// Refresh page without submitting forms again
				function refreshPage() {
					if (unsafeWindow.top.location.href.includes('#')) {
						unsafeWindow.top.location.reload();
					} else {
						unsafeWindow.top.location.href = unsafeWindow.top.location.href;
					}
				}

				// Check whether current page url matches FuncInfo.checker rule
				// This code is copy and modified from FunctionLoader.check
				function testChecker(checker) {
					if (!checker) {return true;}
					const values = Array.isArray(checker.value) ? checker.value : [checker.value]
					return values.some(value => {
						switch (checker.type) {
							case 'regurl': {
								return !!location.href.match(value);
							}
							case 'func': {
								try {
									return value();
								} catch (err) {
									DoLog(LogLevel.Error, CONST.Text.Loader.CheckerError);
									DoLog(LogLevel.Error, err);
									return false;
								}
							}
							case 'switch': {
								return value;
							}
							case 'starturl': {
								return location.href.startsWith(value);
							}
							case 'startpath': {
								return location.pathname.startsWidth(value);
							}
							default: {
								DoLog(LogLevel.Error, CONST.Text.Loader.CheckerInvalid);
								return false;
							}
						}
					});
				}

				// Ajax submit form
				// onload(blob_response) | onerror(err_instance)
				// The form serializing algorithm inside this function is not fully implemented yet. See comments in code.
				function submitForm(form, onload, onerror) {
					const data = serializeFormData(form);
					GM_xmlhttpRequest({
						method: 'POST',
						url: form.getAttribute('action'),
						headers: {'Content-Type': 'application/x-www-form-urlencoded'},
						responseType: 'blob',
						data: data,
						onload: resp => typeof onload === 'function' && onload(resp),
						onerror: err => typeof onerror === 'function' && onerror(err),
						ontimeout: err => typeof onerror === 'function' && onerror(err),
					});

					// Serialize form with GBK encoding
					// Supports string data only
					// This is not fully implemented. See https://url.spec.whatwg.org/#concept-urlencoded-serializer and https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm for further implementation
					function serializeFormData(form) {
						const data = new FormData(form);
						let string = '';
						for (const [key, value] of data) {
							string += `&${formEncode(key)}=${formEncode(value)}`;
						}
						return string;
					}
				}

				function formEncode(str) {
					return $URL.encode(normalizeLinefeeds(str));

					// Code from https://github.com/jimmywarting/FormData/ (MIT License)
					function normalizeLinefeeds(value) {
						return value.replace(/\r?\n|\r/g, '\r\n')
					}
				}

				// Load all function-objs provided in funcs asynchronously, and merge return values into one return obj
				// funcobj: {[checker], [detectDom], func}
				function loadFuncs(oFuncs) {
					const returnObj = {};

					oFuncs.forEach(oFunc => {
						if (!oFunc.checker || testChecker(oFunc.checker)) {
							if (oFunc.detectDom) {
								detectDom(oFunc.detectDom, e => execute(oFunc));
							} else {
								setTimeout(e => execute(oFunc), 0);
							}
						}
					});

					return returnObj;

					function execute(oFunc) {
						setTimeout(e => {
							const rval = oFunc.func(returnObj) || {};
							copyProps(rval, returnObj);
						}, 0);
					}
				}

				// Encrypt given text with key using AES-GCM (See https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto/encrypt)
				// Returns: Encrypted string
				function encrypt(text, secret) {
					return CryptoJS.AES.encrypt(text, secret).toString();
				}

				// Decrypt given ArrayBuffer with key and iv using AES-GCM
				// Returns: Promise(text)
				function decrypt(ciphertext, secret) {
					const bytes  = CryptoJS.AES.decrypt(ciphertext, secret);
					const originalText = bytes.toString(CryptoJS.enc.Utf8);
					return originalText;
				}

				function getOS() {
					const info = (navigator.platform || navigator.userAgent).toLowerCase();
					const test = (s) => (info.includes(s));
					const map = {
						'Windows': ['window', 'win32', 'win64', 'win86'],
						'Mac': ['mac', 'os x'],
						'Linux': ['linux']
					}
					for (const [sys, strs] of Object.entries(map)) {
						if (strs.some(test)) {
							return sys;
						}
					}
					return 'Null';
				}

				// Get a time text like 1970-01-01 00:00:00
				// if dateSpliter provided false, there will be no date part. The same for timeSpliter.
				function getTime() {
					const [time, dateSpliter, timeSpliter] = parseArgs([...arguments], [
						[1],
						[2, 3],
						[1, 2, 3]
					], [new Date().getTime(), '-', ':']);

					const d = new Date(time);
					let fulltime = '';
					dateSpliter && (fulltime += [d.getFullYear().toString().padStart(4, '0'), (d.getMonth() + 1).toString().padStart(2, '0'), d.getDate().toString().padStart(2, '0')].join(dateSpliter));
					dateSpliter && timeSpliter && (fulltime += ' ');
					timeSpliter && (fulltime += [d.getHours().toString().padStart(2, '0'), d.getMinutes().toString().padStart(2, '0'), d.getSeconds().toString().padStart(2, '0')].join(timeSpliter));
					return fulltime;
				}

				// Deep copy an object
				function deepClone(obj) {
					/* No need for structuredClone
					if (typeof structuredClone === 'function') {
						return structuredClone(obj);
					}
					*/

					let newObj = typeof obj === 'object' && obj !== null ? (Array.isArray(obj) ? [] : {}) : obj;
					if (obj && typeof obj === "object") {
						for (let key in obj) {
							if (obj.hasOwnProperty(key)) {
								newObj[key] = (obj && typeof obj[key] === 'object') ? deepClone(obj[key]) : obj[key];
							}
						}
					}
					return newObj;
				}
			}
		},

		// Dialog
		{
			name: '对话框',
			description: '基础对话框功能,用于构建对话框式界面',
			id: 'dialog',
			system: true,
			checker: {
				type: 'switch',
				value: false
			},
			func: function() {
				addStyle('.dialog-mask {position: fixed;left: 0;top: 0;width: 100vw;height: 100vh;overflow: hidden;background: #FFFFFF20;z-index: 100;}.dialog-container {position: fixed;left: 0;top: 0;width: 100vw;height: 100vh;overflow: hidden;background: #00000000;z-index: 101;display: flex;align-items: center;justify-content: center;}.dialog-main {border-radius: 5px;background: #FFFFFF;box-shadow: 5px 5px 5px 0px rgba(0, 0, 0, 0.50);padding: 2vh 2vw;width: fit-content;}.plus-darkmode .dialog-main {background: #222222;}', 'plus-dialog');
				class dialog {
					#elements;
					#showing;

					constructor(content) {
						const elements = this.#elements = {};
						elements.mask = $$CrE({
							tagName: 'div',
							classes: 'dialog-mask'
						});
						elements.container = $$CrE({
							tagName: 'div',
							classes: 'dialog-container'
						});
						elements.main = $$CrE({
							tagName: 'div',
							classes: 'dialog-main'
						});
						elements.container.appendChild(elements.main);
						[elements.mask, elements.container].forEach(elm => document.body.appendChild(elm));

						if (typeof content === 'string') {
							elements.main.innerHTML = content;
						} else if (content instanceof HTMLElement) {
							elements.main.appendChild(content);
						}
						content ? this.show() : this.hide();
					}

					show() {
						['mask', 'container'].forEach(name => this.#elements[name].style.removeProperty('display'));
						this.#showing = true;
					}

					hide() {
						['mask', 'container'].forEach(name => this.#elements[name].style.display = 'none');
						this.#showing = false;
					}

					get elements() {
						return Object.assign({}, this.#elements);
					}

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

					set showing(visible) {
						if (typeof visible === 'boolean') {
							visible ? this.show() : this.hide();
						}
						return this.#showing;
					}
				}

				return dialog;
			}
		},

		// SidePanel
		{
			name: '侧边工具栏',
			description: '基本组件 - 工具栏按钮管理器',
			id: 'SidePanel',
			system: true,
			func: function() {
				const utils = require('utils');
				const CONST = {
					CSS: {
						Sidepanel: '#sidepanel-panel {background-color: #00000000;z-index: 4000;}.sidepanel-button {font-size: 2vmin;color: #1E64DC;fill: #1E64DC;background-color: #FDFDFD;}.sidepanel-button:hover, .sidepanel-button.low-opacity:hover {opacity: 1;color: #FDFDFD;background-color: #1E64DC;}.sidepanel-button.low-opacity{opacity: 0.4 }.sidepanel-button>:is(i[class^="fa-"],svg) {line-height: 3vmin;width: 3vmin;height: 3vmin;}.sidepanel-button[class*="tooltip"]:hover::after {font-size: 0.9rem;top: calc((5vmin - 25px) / 2);}.sidepanel-button[class*="tooltip"]:hover::before {top: calc((5vmin - 12px) / 2);}.sidepanel-button.accept-pointer{pointer-events:auto;}',
						DefaultSVG: '.default-fa-spin {-webkit-animation-name: default-fa-spin;animation-name: default-fa-spin;-webkit-animation-duration: var(--fa-animation-duration,2s);animation-duration: var(--fa-animation-duration,2s);-webkit-animation-iteration-count: var(--fa-animation-iteration-count,infinite);animation-iteration-count: var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function: var(--fa-animation-timing,linear);animation-timing-function: var(--fa-animation-timing,linear);}@-webkit-keyframes default-fa-spin {0% {-webkit-transform: rotate(0deg);transform: rotate(0deg) }to {-webkit-transform: rotate(1turn);transform: rotate(1turn) }}@keyframes default-fa-spin {0% {-webkit-transform: rotate(0deg);transform: rotate(0deg) }to {-webkit-transform: rotate(1turn);transform: rotate(1turn) }}',
					},
					SVG: {
						DefaultSVG: '<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"/></svg>'
					}
				};
				return sideFunctions();

				// Side functions area
				function sideFunctions() {
					const SPanel = new SidePanel();
					SPanel.create();
					SPanel.setPosition('bottom-right');
					addStyle(CONST.CSS.Sidepanel, 'plus-sidepanel-css');
					addStyle(CONST.CSS.DefaultSVG, 'plus-sidepanel-defaultsvg');

					commonButtons();
					return SPanel;

					function commonButtons() {
						// Button show/hide-all-buttons
						const btnShowHide = SPanel.add({
							faicon: 'fa-solid fa-down-left-and-up-right-to-center',
							className: 'accept-pointer',
							tip: '隐藏面板',
							onclick: (function() {
								let hidden = false;
								return (e) => {
									hidden = !hidden;
									btnShowHide.faicon.className = 'fa-solid ' + (hidden ? 'fa-up-right-and-down-left-from-center' : 'fa-down-left-and-up-right-to-center');
									btnShowHide.classList[hidden ? 'add' : 'remove']('low-opacity');
									SPanel.setTooltip(btnShowHide, (hidden ? '显示面板' : '隐藏面板'));
									SPanel.elements.panel.style.pointerEvents = hidden ? 'none' : 'auto';
									for (const button of SPanel.elements.buttons) {
										if (button === btnShowHide) {continue;}
										//button.style.display = hidden ? 'none' : 'block';
										button.style.pointerEvents = hidden ? 'none' : 'auto';
										button.style.opacity = hidden ? '0' : '1';
									}
								};
							}) ()
						});

						// Button scroll-to-bottom
						const btnDown = SPanel.add({
							faicon: 'fa-solid fa-angle-down',
							tip: '转到底部',
							onclick: e => {
								const elms = [document.body.parentElement, $('#content'), $('#contentmain')];

								for (const elm of elms) {
									elm && elm.scrollTo && elm.scrollTo(elm.scrollLeft, elm.scrollHeight);
								}
							}
						});

						// Button scroll-to-top
						const btnUp = SPanel.add({
							faicon: 'fa-solid fa-angle-up',
							tip: '转到顶部',
							onclick: e => {
								const elms = [document.body.parentElement, $('#content'), $('#contentmain')];

								for (const elm of elms) {
									elm && elm.scrollTo && elm.scrollTo(elm.scrollLeft, 0);
								}
							}
						});

						// Darkmode (old version)
						/*
					const btnDarkmode = SPanel.add({
						faicon: 'fa-solid ' + (DMode.isActivated() ? 'fa-sun' : 'fa-moon'),
						tip: '明暗切换',
						onclick: (e) => {
							DMode.toggle();
							btnDarkmode.faicon.className = 'fa-solid ' + (DMode.isActivated() ? 'fa-sun' : 'fa-moon');
						}
					});
					*/

						// Refresh page
						const btnRefresh = SPanel.add({
							faicon: 'fa-solid fa-rotate-right',
							tip: '刷新页面',
							onclick: e => utils.refreshPage()
						});
					}
				}

				// Side-located control panel
				// Requirements: FontAwesome, tippy.js, addStyle
				// Use 'new' keyword
				function SidePanel() {
					// Public SP
					const SP = this;
					const elms = SP.elements = {};

					// Private _SP
					// keys start with '_' shouldn't be modified
					const _SP = {
						_id: {
							css: 'sidepanel-style',
							usercss: 'sidepanel-style-user',
							panel: 'sidepanel-panel'
						},
						_class: {
							button: 'sidepanel-button'
						},
						_directions: ['left', 'right', 'top', 'bottom']
					};

					addStyle('#sidepanel-panel {position: fixed; background-color: #00000000; padding: 0.5vmin; line-height: 3.5vmin; height: auto; display: flex; transition-duration: 0.3s; z-index: 9999999999;} #sidepanel-panel.right {right: 3vmin;} #sidepanel-panel.bottom {bottom: 3vmin; flex-direction: column-reverse;} #sidepanel-panel.left {left: 3vmin;} #sidepanel-panel.top {top: 3vmin; flex-direction: column;} .sidepanel-button {padding: 1vmin; margin: 0.5vmin; font-size: 3.5vmin; border-radius: 10%; text-align: center; color: #00000088; background-color: #FFFFFF88; box-shadow:3px 3px 2px #00000022; user-select: none; transition-duration: inherit;} .sidepanel-button:hover {color: #FFFFFFDD; background-color: #000000DD;}', 'plus-sidepanel');

					SP.create = function() {
						// Create panel
						const panel = elms.panel = $CrE('div');
						panel.id = _SP._id.panel;
						SP.setPosition('bottom-right');
						detectDom('body', e => document.body.appendChild(panel));

						// Prepare buttons
						elms.buttons = [];
					}

					// Insert a button to given index
					// details = {index, text, faicon, id, tip, className, onclick, listeners}, all optional
					// listeners = [..[..args]]. [..args] will be applied as button.addEventListener's args
					// faicon = 'fa-icon-name-classname fa-icon-style-classname', this arg stands for a FontAwesome icon to be inserted inside the botton
					// Returns the button(HTMLDivElement), including button.faicon(HTMLElement/HTMLSpanElement in firefox, <i>) if faicon is set
					SP.insert = function(details) {
						const index = details.index;
						const text = details.text;
						const faicon = details.faicon;
						const id = details.id;
						const tip = details.tip;
						const className = details.className;
						const onclick = details.onclick;
						const listeners = details.listeners || [];

						const button = $CrE('div');
						text && (button.innerHTML = text);
						id && (button.id = id);
						tip && setTooltip(button, tip);
						className && (button.className = className);
						onclick && (button.onclick = onclick);
						if (faicon) {
							// Show default svg before FontAwesome loaded
							button.innerHTML = CONST.SVG.DefaultSVG;
							button.children[0].classList.add('default-fa-spin');

							// Show faicon when FontAwesome loaded
							FL_recieveMessage('FontAwesomeLoaded', function() {
								// Remove default svg
								[...button.children].forEach(child => child.remove());

								// Show faicon
								const i = $CrE('i');
								i.className = faicon;
								button.faicon = i;
								button.appendChild(i);
							}, 'FontAwesome', true);
						}
						for (const listener of listeners) {
							$AEL(button, ...listener);
						}
						button.classList.add(_SP._class.button);

						elms.buttons = insertItem(elms.buttons, button, index);
						index < elms.buttons.length ? elms.panel.insertBefore(button, elms.panel.children[index]) : elms.panel.appendChild(button);
						return button;
					}

					// Append a button
					SP.add = function(details) {
						details.index = elms.buttons.length;
						return SP.insert(details);
					}

					// Remove a button
					SP.remove = function(arg) {
						let index, elm;
						if (arg instanceof HTMLElement) {
							elm = arg;
							index = elms.buttons.indexOf(elm);
						} else if (typeof(arg) === 'number') {
							index = arg;
							elm = elms.buttons[index];
						} else if (typeof(arg) === 'string') {
							elm = $(elms.panel, arg);
							index = elms.buttons.indexOf(elm);
						}

						elms.buttons = delItem(elms.buttons, index);
						elm.remove();
					}

					// Show entire panel
					SP.show = function() {
						elms.panel.style.removeProperty('display');
					}

					// Hide entire panel
					SP.hide = function() {
						elms.panel.style.display = 'none';
					}

					// Sets the display position by texts like 'right-bottom'
					SP.setPosition = function(pos) {
						const poses = _SP.direction = pos.split('-');
						const avails = _SP._directions;

						// Available check
						if (poses.length !== 2) {return false;}
						for (const p of poses) {
							if (!avails.includes(p)) {return false;}
						}

						// remove all others
						for (const p of avails) {
							elms.panel.classList.remove(p);
						}

						// add new pos
						for (const p of poses) {
							elms.panel.classList.add(p);
						}

						// Change tooltips' direction
						elms.buttons && elms.buttons.forEach(setTooltipDirection);
					}

					// Gets the current display position
					SP.getPosition = function() {
						return _SP.direction.join('-');
					}

					SP.setTooltip = setTooltip;
					SP.setTooltipDirection = setTooltipDirection;

					// Append a style text to document(<head>) with a <style> element
					// Replaces existing id-specificed <style>s
					function spAddStyle(css, id) {
						const style = document.createElement("style");
						id && (style.id = id);
						style.textContent = css;
						for (const elm of $All('#'+id)) {
							elm.parentElement && elm.parentElement.removeChild(elm);
						}
						document.head.appendChild(style);
					}

					// Set a tooltip to the element
					function setTooltip(elm, text, direction='auto') {
						elm._tippy ? elm._tippy.setContent(text) : tippy(elm, {
							content: text,
							theme: 'wenku_tip',
							arrow: true,
							hideOnClick: false
						});

						setTooltipDirection(elm, direction);
					}

					function setTooltipDirection(elm, direction='auto') {
						direction === 'auto' && (direction = _SP.direction.includes('left') ? 'right' : 'left');
						if (!_SP._directions.includes(direction)) {Err('setTooltip: invalid direction');}

						// Tippy direction
						if (!elm._tippy) {
							DoLog(LogLevel.Error, 'SidePanel.setTooltipDirection: Given elm has no tippy instance(elm._tippy)');
							Err('SidePanel.setTooltipDirection: Given elm has no tippy instance(elm._tippy)');
						}
						elm._tippy.setProps({
							placement: direction
						});
					}

					// Del an item from an array using its index. Returns the array but can NOT modify the original array directly!!
					function delItem(arr, index) {
						arr = arr.slice(0, index).concat(arr.slice(index+1));
						return arr;
					}

					// Insert an item into an array using given index. Returns the array but can NOT modify the original array directly!!
					function insertItem(arr, item, index) {
						arr = arr.slice(0, index).concat(item).concat(arr.slice(index));
						return arr;
					}
				}
			}
		},

		// SettingPanel
		{
			name: 'SettingPanel',
			description: '基本组件 - 设置类窗口',
			id: 'SettingPanel',
			system: true,
			checker: {
				type: 'switch',
				value: true
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');
				const CONST = {
					Text: {
						Saved: '已保存',
						Reset: '已恢复到修改前',
						Boolean: ['否', '是'],
						Operations: '操作',
						Operation: {
							edit: '编辑',
							delete: '删除'
						}
					}
				};
				const SettingOptionElements = {
					'string': {
						createElement: function() {const e = $CrE('input'); e.style.width = 'calc(100% - 6px - 1ex)'; return e;},
						setValue: function(val) {this.element.value = val;},
						getValue: function() {return this.element.value;},
					},
					'number': {
						createElement: function() {const e = $CrE('input'); e.type = 'number'; e.style.width = 'calc(100% - 6px - 1ex)'; return e;},
						setValue: function (val) {this.element.value = val;},
						getValue: function() {return this.element.value;},
					},
					'boolean': {
						createElement: function() {const e = $CrE('input'); e.type = 'checkbox'; return e;},
						setValue: function(val) {this.element.checked = val;},
						getValue: function(data) {return this.element.checked ? (this.hasOwnProperty('data') ? this.data.checked : true) : (data ? this.data.unchecked : false);},
					},
					'select': {
						createElement: function() {const e = $CrE('select'); this.hasOwnProperty('data') && this.data.forEach((d) => {const o = $CrE('option'); o.innerText = d; e.appendChild(o)}); return e;},
						setValue: function(val) {Array.from(this.element.children).find((opt) => (opt.value === val)).selected = true;},
						getValue: function() {return this.element.value;},
					}
				}

				// initialize
				alertify.dialog('setpanel', function factory(){
					return {
						// The dialog startup function
						// This will be called each time the dialog is invoked
						// For example: alertify.myDialog( data );
						main:function(){
							// Split arguments
							let content, header, buttons, onsave, onreset, onclose;
							switch (arguments.length) {
								case 1:
									switch (typeof arguments[0]) {
										case 'string':
											content = arguments[0];
											break;
										case 'object':
											arguments[0].hasOwnProperty('content') && (content = arguments[0].content);
											arguments[0].hasOwnProperty('header')  && (header = arguments[0].header);
											arguments[0].hasOwnProperty('buttons') && (buttons = arguments[0].buttons);
											arguments[0].hasOwnProperty('onsave')  && (onsave = arguments[0].onsave);
											arguments[0].hasOwnProperty('onreset') && (onreset = arguments[0].onreset);
											arguments[0].hasOwnProperty('onclose') && (buttons = arguments[0].onclose);
											break;
										default:
											Err('Arguments invalid', 1);
									}
									break;
								case 2:
									content = arguments[0];
									header = arguments[1];
									break;
								case 3:
									content = arguments[0];
									header = arguments[1];
									buttons = buttons[2];
									break;
							}

							// Prepare dialog
							this.resizeTo('80%', '80%');
							content && this.setContent(content);
							header  && this.setHeader(header);
							onsave  && this.set('onsave', onsave);
							onreset && this.set('onreset', onreset);
							onclose && this.set('onclose', onclose);

							// Choose & show selected button groups
							const btnGroups = {
								// Close button only
								basic: [[1, 0]],

								// Save & reset button
								saver: [[0, 0], [1, 1]]
							};
							const group = btnGroups[buttons || 'basic'];
							const divs = ['auxiliary', 'primary'];
							divs.forEach((div) => {
								Array.from(this.elements.buttons[div].children).forEach((btn) => {
									btn.style.display = 'none';
								});
							});
							group.forEach((button) => {
								this.elements.buttons[divs[button[0]]].children[button[1]].style.display = '';
							});

							return this;
						},
						// The dialog setup function
						// This should return the dialog setup object ( buttons, focus and options overrides ).
						setup:function(){
							return {
								/* buttons collection */
								buttons:[{
									/* button label */
									text: '恢复到修改前',

									/*bind a keyboard key to the button */
									key: undefined,

									/* indicate if closing the dialog should trigger this button action */
									invokeOnClose: false,

									/* custom button class name  */
									className: alertify.defaults.theme.cancel,

									/* custom button attributes  */
									attrs: {},

									/* Defines the button scope, either primary (default) or auxiliary */
									scope:'auxiliary',

									/* The will conatin the button DOMElement once buttons are created */
									element: undefined
								},{
									/* button label */
									text: '关闭',

									/*bind a keyboard key to the button */
									key: undefined,

									/* indicate if closing the dialog should trigger this button action */
									invokeOnClose: true,

									/* custom button class name  */
									className: alertify.defaults.theme.ok,

									/* custom button attributes  */
									attrs: {},

									/* Defines the button scope, either primary (default) or auxiliary */
									scope:'primary',

									/* The will conatin the button DOMElement once buttons are created */
									element: undefined
								},{
									/* button label */
									text: '保存',

									/*bind a keyboard key to the button */
									key: undefined,

									/* indicate if closing the dialog should trigger this button action */
									invokeOnClose: false,

									/* custom button class name  */
									className: alertify.defaults.theme.ok,

									/* custom button attributes  */
									attrs: {},

									/* Defines the button scope, either primary (default) or auxiliary */
									scope:'primary',

									/* The will conatin the button DOMElement once buttons are created */
									element: undefined
								}],

								/* default focus */
								focus:{
									/* the element to receive default focus, has differnt meaning based on value type:
										number:     action button index.
										string:     querySelector to select from dialog body contents.
										function:   when invoked, should return the focus element.
										DOMElement: the focus element.
										object:     an object that implements .focus() and .select() functions.
									*/
									element: 0,

									/* indicates if the element should be selected on focus or not*/
									select: true

								},
								/* dialog options, these override the defaults */
								options: {
									title: 'Setting Panel',
									modal: true,
									basic: false,
									frameless: false,
									pinned: false,
									movable: true,
									moveBounded: false,
									resizable: true,
									autoReset: false,
									closable: true,
									closableByDimmer: true,
									maximizable: false,
									startMaximized: false,
									pinnable: false,
									transition: 'fade',
									padding: true,
									overflow: true,
									/*
									onshow:...,
									onclose:...,
									onfocus:...,
									onmove:...,
									onmoved:...,
									onresize:...,
									onresized:...,
									onmaximize:...,
									onmaximized:...,
									onrestore:...,
									onrestored:...
									*/
								}
							};
						},
						// This will be called once the dialog DOM has been created, just before its added to the document.
						// Its invoked only once.
						build:function(){

							// Do custom DOM manipulation here, accessible via this.elements

							// this.elements.root           ==> Root div
							// this.elements.dimmer         ==> Modal dimmer div
							// this.elements.modal          ==> Modal div (dialog wrapper)
							// this.elements.dialog         ==> Dialog div
							// this.elements.reset          ==> Array containing the tab reset anchor links
							// this.elements.reset[0]       ==> First reset element (button).
							// this.elements.reset[1]       ==> Second reset element (button).
							// this.elements.header         ==> Dialog header div
							// this.elements.body           ==> Dialog body div
							// this.elements.content        ==> Dialog body content div
							// this.elements.footer         ==> Dialog footer div
							// this.elements.resizeHandle   ==> Dialog resize handle div

							// Dialog commands (Pin/Maximize/Close)
							// this.elements.commands           ==> Object containing  dialog command buttons references
							// this.elements.commands.container ==> Root commands div
							// this.elements.commands.pin       ==> Pin command button
							// this.elements.commands.maximize  ==> Maximize command button
							// this.elements.commands.close     ==> Close command button

							// Dialog action buttons (Ok, cancel ... etc)
							// this.elements.buttons                ==>  Object containing  dialog action buttons references
							// this.elements.buttons.primary        ==>  Primary buttons div
							// this.elements.buttons.auxiliary      ==>  Auxiliary buttons div

							// Each created button will be saved with the button definition inside buttons collection
							// this.__internal.buttons[x].element

						},
						// This will be called each time the dialog is shown
						prepare:function(){
							// Do stuff that should be done every time the dialog is shown.
						},
						// This will be called each time an action button is clicked.
						callback:function(closeEvent){
							//The closeEvent has the following properties
							//
							// index: The index of the button triggering the event.
							// button: The button definition object.
							// cancel: When set true, prevent the dialog from closing.
							const myEvent = utils.deepClone(closeEvent);
							switch (closeEvent.index) {
								case 0: {
									// Reset button
									closeEvent.cancel = myEvent.cancel = true;
									myEvent.save = false;
									myEvent.reset = true;
									const onreset = this.get('onreset');
									typeof onreset === 'function' && onreset(myEvent);
									break;
								}
								case 1: {
									// Close button
									// Do something here if need
									break;
								}
								case 2: {
									// Save button
									closeEvent.cancel = myEvent.cancel = true;
									myEvent.save = true;
									myEvent.reset = false;
									const onsave = this.get('onsave');
									typeof onsave === 'function' && onsave(myEvent);
								}
							}
							myEvent.save && this.get('saver').call(this);
							myEvent.reset && this.get('reseter').call(this);
							closeEvent.cancel = myEvent.cancel;
						},
						// To make use of AlertifyJS settings API, group your custom settings into a settings object.
						settings:{
							onsave: function() {},
							onreset: function() {},
							options: [], // SettingOption array
							saver: function() {
								this.get('options').forEach(o => o.save());
							},
							reseter: function() {
								this.get('options').forEach(o => o.reset());
							}
						},
						// AlertifyJS will invoke this each time a settings value gets updated.
						settingUpdated:function(key, oldValue, newValue){
							// Use this to respond to specific setting updates.
							const _this = this;
							['onsave', 'onreset', 'saver', 'reseter'].includes(key) && check('function');
							['options'].includes(key) && check(Array);

							function rollback() {
								_this.set(key, oldValue);
							}

							function check(type) {
								valid(oldValue, type) && !valid(newValue, type) && rollback();
							}

							function valid(value, type) {
								return ({
									'string': () => (typeof value === type),
									'function': () => (value instanceof type)
								})[typeof type]();
							}
						},
						// listen to internal dialog events.
						hooks:{
							// triggered when the dialog is shown, this is seperate from user defined onshow
							onshow: function() {
								this.resizeTo('80%', '80%');
							},
							// triggered when the dialog is closed, this is seperate from user defined onclose
							onclose: function() {
								const onclose = this.get('onclose');
								typeof onclose === 'function' && onclose();
							},
							// triggered when a dialog option gets updated.
							// IMPORTANT: This will not be triggered for dialog custom settings updates ( use settingUpdated instead).
							onupdate: function() {
							}
						}
					}
				}, true);

				return {
					easyStorage,
					easySettings,
					SettingPanel,
					SettingOption,
					optionAvailable,
					isOption,
					registerElement,
				};

				// Storage management panel for structured data like [{prop1, prop2, ...}, ...]
				// details: {String path, String key, [title|panel],
				//     props: {'prop1': {type: 'string'/'number'/'time'/'boolean', name, [editable], [listeners], [styles], [oncreate]}}},
				//     operations: [{type: 'delete'/'edit', [text]}, ...]
				function easyStorage(details, CM) {
					!optionAvailable('storage') && register();

					const CONFIG = CM.Config;
					const panel = details.panel || new SettingPanel({
						buttons: 'saver',
						header: details.title || ''
					}, CM);

					const option = new SettingOption({ type: 'storage', data: { details, panel }, path: details.path }, CM);
					panel.alertifyBox.get('options').push(option);
					panel.appendTable(option.data.table);

					function register() {
						registerElement('storage', {
							createElement() {
								this.data = {...this.data};
								const panel = this.data.panel;
								const details = this.data.details;

								const colCount = Object.keys(details.props).length + details.operations.length;
								const table = new panel.PanelTable();
								this.data.table = table;
								makeHeader(this);

								return table.element;
							},
							setValue(val) {
								this.data.data = val;
								this.data.table.rows.slice().forEach(row => row.remove());
								makeHeader(this);
								makeRows(this, val);
							},
							getValue() {
								return this.data.data;
							}
						});

						function makeHeader(Option) {
							const panel = Option.data.panel;
							const table = Option.data.table;
							const details = Option.data.details;

							const colCount = Object.keys(details.props).length + details.operations.length;
							table.appendRow({
								blocks: [{
									isHeader: true,
									colSpan: colCount,
									innerText: details.title,
								}]
							});
							table.appendRow({
								blocks: [
									...Object.values(details.props).map(p => ({
										innerText: p.name,
										width: p.width || Math.floor(100 / colCount).toString() + '%',
										style: { 'text-align': 'center' }
									})),
									{ innerText: CONST.Text.Operations, colSpan: details.operations.length, style: { 'text-align': 'center' } },
								]
							});
						}

						function makeRows(Option, storageArr) {
							const panel = Option.data.panel;
							const table = Option.data.table;
							const details = Option.data.details;

							for (const item of storageArr) {
								const row = new panel.PanelRow();

								// Properties
								for (const [p, prop] of Object.entries(details.props)) {
									const block = new panel.PanelBlock({
										innerText: {
											string: v => v,
											number: v => v.toString(),
											time: v => utils.getTime(v),
											boolean: v => CONST.Text.Boolean[+v]
										}[prop.type](item[p]),
										style: { 'text-align': 'center', ...(prop.styles || {}) },
										listeners: (prop.listeners || []).map(listener => {
											const lis = [...listener];
											const func = lis[1];
											lis[1] = function() { func.apply(this, [item[details.key], ...arguments]); }
											return lis;
										})
									});
									typeof prop.oncreate === 'function' && prop.oncreate(item[details.key], block);
									row.appendBlock(block);
								}

								// Operations
								details.operations.map(op => $$CrE({
									tagName: 'span',
									props: { innerText: op.text || CONST.Text.Operation[op.type] },
									classes: [CommonStyle.ClassName.Button],
									listeners: [['click', e => {
										const items = Option.data.data;
										const index = items.findIndex(it => it[details.key] === item[details.key]);
										({
											delete: e => {
												items.splice(index, 1);
												row.remove();
												table.element.dispatchEvent(new Event('change'));
											},
											edit: e => { /* Edit function here */ console.log(`edit ${item[details.key]}`); },
											func: () => Option.data.data = op.func(e, items, index)
										})[op.type]();
									}]]
								})).forEach(span => row.appendBlock({ children: [span], style: { 'text-align': 'center' } }));

								table.appendRow(row);
							}
						}
					}
				}

				// Creates a simple SettingPanel in a few code (or append tables to given settingpanel)
				// details: {Array areas, [title, panel]} or area; area: {title, itmes: [{text, path, type}]}
				function easySettings(details, CM) {
					details = details.areas ? details : {areas: [details], title: details.title};
					const panel = details.panel || new SettingPanel({
						buttons: 'saver',
						header: details.title
					}, CM);

					for (const area of details.areas) {
						const table = new panel.PanelTable({
							rows: area.title ? [{
								blocks: [{
									isHeader: true,
									colSpan: 2,
									innerText: area.title,
								}]
							}] : []
						});
						for (const item of area.items) {
							const option = {};
							copyProps(item, option, ['type', 'path', 'checker', 'children']);
							table.appendRow({
								blocks: [{
									innerText: item.text
								}, {
									options: [option]
								}]
							});
						}
						panel.appendTable(table);
					}

					return panel;
				}

				// A table-based setting panel using alertify-js
				// For wenku8++ only version
				// Use 'new' keyword
				// Usage:
				/*
					var panel = new SettingPanel({
						buttons: 0,
						header: '',
						className: '',
						id: '',
						name: '',
						tables: [
							{
								className: '',
								id: '',
								name: '',
								rows: [
									{
										className: '',
										id: '',
										name: '',
										blocks: [
											{
												isHeader: false,
												width: '',
												height: '',
												innerHTML / innerText: ''
												colSpan: 1,
												rowSpan: 1,
												className: '',
												id: '',
												name: '',
												options: [SettingOption, ...]
												children: [HTMLElement, ...]
											},
											...
										]
									},
									...
								]
							},
							...
						]
					});
				*/
				function SettingPanel(details={}, CM=null) {
					const SP = this;
					SP.insertTable = insertTable;
					SP.appendTable = appendTable;
					SP.removeTable = removeTable;
					SP.remove = remove;
					SP.PanelTable = PanelTable;
					SP.PanelRow = PanelRow;
					SP.PanelBlock = PanelBlock;

					// <div> element
					const elm = $CrE('div');
					copyProps(details, elm, ['id', 'name', 'className']);
					elm.classList.add('settingpanel-container');

					// Make alerity box
					const box = SP.alertifyBox = alertify.setpanel({
						onsave: function() {
							alertify.notify(CONST.Text.Saved);
						},
						onreset: function() {
							alertify.notify(CONST.Text.Reset);
						},
						buttons: details.hasOwnProperty('buttons') ? details.buttons : 'basic'
					});
					clearChildNodes(box.elements.content);
					box.elements.content.appendChild(elm);
					box.elements.content.style.overflow = 'auto';
					box.setHeader(details.header);
					box.setting({
						maximizable: true,
						overflow: true
					});
					details.onclose && box.setting('onclose', details.onclose);
					!box.isOpen() && box.show();

					// Configure object
					let css='', usercss='';
					SP.element = elm;
					SP.tables = [];
					SP.length = 0;
					copyProps(details, SP, ['id', 'name']);
					Object.defineProperty(SP, 'css', {
						configurable: false,
						enumerable: true,
						get: function() {
							return css;
						},
						set: function(_css) {
							addStyle(_css, 'settingpanel-css');
							css = _css;
						}
					});
					Object.defineProperty(SP, 'usercss', {
						configurable: false,
						enumerable: true,
						get: function() {
							return usercss;
						},
						set: function(_usercss) {
							addStyle(_usercss, 'settingpanel-usercss');
							usercss = _usercss;
						}
					});
					SP.css = `.settingpanel-table {border-spacing: 0px; border-collapse: collapse; width: 100%; margin: 2em 0;} .settingpanel-block {border: 1px solid ${CommonStyle.Color.Text}; text-align: center; vertical-align: middle; padding: 3px; text-align: left;} .settingpanel-header {font-weight: bold;} td.settingpanel-block:nth-of-type(1):is(:not([colspan]),[colspan="1"]){width: 30%;}`;

					// Create tables
					if (details.tables) {
						for (const table of details.tables) {
							if (table instanceof PanelTable) {
								appendTable(table);
							} else {
								appendTable(new PanelTable(table));
							}
						}
					}

					// Insert a Panel-Row
					// Returns Panel object
					function insertTable(table, index) {
						// Insert table
						!(table instanceof PanelTable) && (table = new PanelTable(table));
						index < SP.length ? elm.insertBefore(table.element, elm.children[index]) : elm.appendChild(table.element);
						insertItem(SP.tables, table, index);
						table.id !== undefined && (SP.children[table.id] = table);
						SP.length++;

						// Set parent
						table.parent = SP;
					}

					// Append a Panel-Row
					// Returns Panel object
					function appendTable(table) {
						return insertTable(table, SP.length);
					}

					// Remove a Panel-Row
					// Returns Panel object
					function removeTable(index) {
						const table = SP.tables[index];
						SP.element.removeChild(table.element);
						removeItem(SP.rows, index);
						return SP;
					}

					// Remove itself from parentElement
					// Returns Panel object
					function remove() {
						SP.element.parentElement && SP.parentElement.removeChild(SP.element);
						return SP;
					}

					// Panel-Table object
					// Use 'new' keyword
					function PanelTable(details={}) {
						const PT = this;
						PT.insertRow = insertRow;
						PT.appendRow = appendRow;
						PT.removeRow = removeRow;
						PT.remove = remove

						// <table> element
						const elm = $CrE('table');
						copyProps(details, elm, ['id', 'name', 'className']);
						elm.classList.add('settingpanel-table');

						// Configure
						PT.element = elm;
						PT.rows = [];
						PT.length = 0;
						copyProps(details, PT, ['id', 'name']);

						// Append rows
						if (details.rows) {
							for (const row of details.rows) {
								if (row instanceof PanelRow) {
									insertRow(row);
								} else {
									insertRow(new PanelRow(row));
								}
							}
						}

						// Insert a Panel-Row
						// Returns Panel-Table object
						function insertRow(row, index) {
							// Insert row
							!(row instanceof PanelRow) && (row = new PanelRow(row));
							index < PT.length ? elm.insertBefore(row.element, elm.children[index]) : elm.appendChild(row.element);
							insertItem(PT.rows, row, index);
							PT.length++;

							// Set parent
							row.parent = PT;
							return PT;
						}

						// Append a Panel-Row
						// Returns Panel-Table object
						function appendRow(row) {
							return insertRow(row, PT.length);
						}

						// Remove a Panel-Row
						// Returns Panel-Table object
						function removeRow(index) {
							const row = PT.rows[index];
							PT.element.removeChild(row.element);
							removeItem(PT.rows, index);
							return PT;
						}

						// Remove itself from parentElement
						// Returns Panel-Table object
						function remove() {
							PT.parent instanceof SettingPanel && PT.parent.removeTable(PT.tables.indexOf(PT));
							return PT;
						}
					}

					// Panel-Row object
					// Use 'new' keyword
					function PanelRow(details={}) {
						const PR = this;
						PR.insertBlock = insertBlock;
						PR.appendBlock = appendBlock;
						PR.removeBlock = removeBlock;
						PR.remove = remove;

						// <tr> element
						const elm = $CrE('tr');
						copyProps(details, elm, ['id', 'name', 'className']);
						elm.classList.add('settingpanel-row');

						// Configure object
						PR.element = elm;
						PR.blocks = [];
						PR.length = 0;
						copyProps(details, PR, ['id', 'name']);

						// Append blocks
						if (details.blocks) {
							for (const block of details.blocks) {
								if (block instanceof PanelBlock) {
									appendBlock(block);
								} else {
									appendBlock(new PanelBlock(block));
								}
							}
						}

						// Insert a Panel-Block
						// Returns Panel-Row object
						function insertBlock(block, index) {
							// Insert block
							!(block instanceof PanelBlock) && (block = new PanelBlock(block));
							index < PR.length ? elm.insertBefore(block.element, elm.children[index]) : elm.appendChild(block.element);
							insertItem(PR.blocks, block, index);
							PR.length++;

							// Set parent
							block.parent = PR;
							return PR;
						};

						// Append a Panel-Block
						// Returns Panel-Row object
						function appendBlock(block) {
							return insertBlock(block, PR.length);
						}

						// Remove a Panel-Block
						// Returns Panel-Row object
						function removeBlock(index) {
							const block = PR.blocks[index];
							PR.element.removeChild(block.element);
							removeItem(PR.blocks, index);
							return PR;
						}

						// Remove itself from parent
						// Returns Panel-Row object
						function remove() {
							PR.parent instanceof PanelTable && PR.parent.removeRow(PR.parent.rows.indexOf(PR));
							return PR;
						}
					}

					// Panel-Block object
					// Use 'new' keyword
					function PanelBlock(details={}) {
						const PB = this;
						PB.remove = remove;

						// <td> element
						const elm = $CrE(details.isHeader ? 'th' : 'td');
						copyProps(details, elm, ['innerText', 'innerHTML', 'colSpan', 'rowSpan', 'id', 'name', 'className']);
						copyProps(details, elm.style, ['width', 'height']);
						details.style && copyProps(details.style, elm.style);
						elm.classList.add('settingpanel-block');
						details.isHeader && elm.classList.add('settingpanel-header');
						details.listeners && details.listeners.forEach(listener => $AEL(elm, ...listener))

						// Configure object
						PB.element = elm;
						copyProps(details, PB, ['id', 'name']);

						// Append to parent if need
						details.parent instanceof PanelRow && (PB.parent = details.parent.appendBlock(PB));

						// Append SettingOptions if exist
						if (details.options) {
							(CM ? details.options : details.options.filter(isOption)).map((o) => (isOption(o) ? o : new SettingOption(o, CM))).forEach(function(option) {
								SP.alertifyBox.get('options').push(option);
								elm.appendChild(option.element);
							});
						}

						// Append child elements if exist
						if (details.children) {
							for (const child of details.children) {
								elm.appendChild(child);
							}
						}

						// Remove itself from parent
						// Returns Panel-Block object
						function remove() {
							PB.parent instanceof PanelRow && PB.parent.removeBlock(PB.parent.blocks.indexOf(PB));
							return PB;
						}
					}

					function insertItem(arr, item, index) {
						arr.splice(index, 0, item);
						return arr;
					}
					function removeItem(arr, index) {
						arr.splice(index, 1);
						return arr;
					}
					function MakeReadonlyObj(val) {
						return isObject(val) ? new Proxy(val, {
							get: function(target, property, receiver) {
								return MakeReadonlyObj(target[property]);
							},
							set: function(target, property, value, receiver) {},
							has: function(target, prop) {}
						}) : val;

						function isObject(value) {
							return ['object', 'function'].includes(typeof value) && value !== null;
						}
					}
				}

				// details = {path='config path', type='config type', data='option data'}
				function SettingOption(details={}, CM) {
					const SO = this;
					SO.save = save;
					SO.reset = reset;

					// Initialize ConfigManager
					!CM && Err('SettingOption requires a ConfigManager instance');
					const CONFIG = CM.Config;

					// Get args
					const options = ['path', 'type', 'checker', 'data', 'autoSave'];
					copyProps(details, SO, options);

					// Get first available type if multiple types provided
					Array.isArray(SO.type) && (SO.type = SO.type.find((t) => (optionAvailable(t))));
					!optionAvailable(SO.type) && Err('Unsupported Panel-Option type: ' + details.type);

					// Create element
					const original_value = CM.getConfig(SO.path);
					const SOE = {
						create: SettingOptionElements[SO.type].createElement.bind(SO),
						get: SettingOptionElements[SO.type].getValue.bind(SO),
						set: v => SettingOptionElements[SO.type].setValue.call(SO, utils.deepClone(v)),
					}
					SO.element = SOE.create();
					SOE.set(original_value);

					// Bind change-checker-saver
					SO.element.addEventListener('change', function(e) {
						if (SO.checker) {
							if (SO.checker(e, SOE.get())) {
								SO.autoSave && save();
							} else {
								// Reset value
								reset();

								// Do some value-invalid reminding here
							}
						} else {
							SO.autoSave && save();
						}
					});

					function save() {
						CM.setConfig(SO.path, SOE.get());
					}

					function reset(save=false) {
						SOE.set(original_value);
						save && CM.setConfig(SO.path, original_value);
					}
				}

				// Check if an settingoption type available
				function optionAvailable(type) {
					return Object.keys(SettingOptionElements).includes(type);
				}

				// Register SettingOption element
				function registerElement(name, obj) {
					const formatOkay = typeof obj.createElement === 'function' && typeof obj.setValue === 'function' && typeof obj.getValue === 'function';
					const noConflict = !SettingOptionElements.hasOwnProperty(name);
					const okay = formatOkay && noConflict;
					okay && (SettingOptionElements[name] = obj);
					return okay;
				}

				function isOption(obj) {
					return obj instanceof SettingOption;
				}

				function clearChildNodes(elm) {[...elm.childNodes].forEach(el => el.remove());}
			}
		},

		// MouseTip
		{
			name: '鼠标提示框',
			description: '兼容与扩充文库自带的鼠标跟随提示框',
			id: 'mousetip',
			system: true,
			func: function() {
				let tipready = tipcheck();
				addStyle('#tips {z-index: 10000;}', 'plus_mousetip');
				tipscroll();
				tipiframe();
				const return_value = {
					get tipready() {return tipready},
					settip: settip,
					showtip: showtip,
					hidetip: hidetip
				};
				return return_value;

				// Check if tipobj is ready, if not, then make it
				function tipcheck() {
					DoLog('checking tipobj...');
					if (typeof unsafeWindow.tipobj === 'object' && unsafeWindow.tipobj !== null) {
						DoLog('tipobj ready');
						return true;
					} else {
						if (typeof(tipinit) === 'function') {
							unsafeWindow.tipinit();
							DoLog('tipinit executed');
							return true;
						} else {
							if (!$('a[href*="/themes/wenku8/theme.js"]')) {
								loadTipScript();
							} else {
								DoLog(LogLevel.Error, 'tip init error: theme script exist but tipobj and tipinit missing');
							}
							return false;
						}
					}

					function loadTipScript() {
						const s = $CrE('script');
						s.src = `https://${location.host}/themes/wenku8/theme.js`;
						document.head.appendChild(s);

						$AEL(s, 'load', e => {
							tipready = true;
							if (document.readyState === 'loading') {
								// Wait for tipinit triggered
								$AEL(unsafeWindow, 'load', tipscroll);
							} else {
								// Too late, call tipinit manually
								unsafeWindow.tipinit();
								tipscroll();
							}
							DoLog('theme script loaded');
						});

						DoLog('theme script appended');
					}
				}

				// New tipobj movement method. Makes sure the tipobj stay close with the mouse.
				function tipscroll() {
					if (!tipready) {return false;}

					DoLog('tipscroll executed. ');
					unsafeWindow.tipobj.style.position = 'fixed';
				}

				function tipiframe() {
					$AEL(unsafeWindow, 'mousemove', tipmoveplus);
					return true;

					function tipmoveplus(e) {
						// Move
						if (unsafeWindow.tipobj) {
							unsafeWindow.tipobj.style.left = e.clientX + unsafeWindow.tipx + 'px';
							unsafeWindow.tipobj.style.top = e.clientY + unsafeWindow.tipy + 'px';
						}

						// Call parent
						if (unsafeWindow !== unsafeWindow.parent) {
							const parent = unsafeWindow.parent;
							const doc = parent.document;
							const iframe = [...$All(doc, 'iframe')].find(ifr => ifr.contentDocument === document);
							const clientRect = iframe.getClientRects()[0];
							const topevt = new CustomEvent('mousemove');
							topevt.clientX = clientRect.left + e.clientX;
							topevt.clientY = clientRect.top + e.clientY;
							parent.dispatchEvent(topevt);
						}
					}
				}

				// show & hide tip when mouse in & out. accepts tip as a string or a function that returns the tip string
				function settip(elm, tip) {
					elm[{'string': 'tiptitle', 'function': 'tipgetter'}[typeof(tip)]] = tip;
					elm.removeEventListener('mouseover', showtip);
					elm.removeEventListener('mouseout', hidetip);
					$AEL(elm, 'mouseover', showtip, {capture: true});
					$AEL(elm, 'mouseout', hidetip, {capture: true});
				}

				function showtip(e) {
					if (!tipready) {return false;}

					switch (typeof e) {
						case 'object': {
							if (e[Symbol.toStringTag] === 'MouseEvent') {
								const elm = e.currentTarget;
								if (elm.tiptitle || elm.tipgetter) {
									const tip = elm.tiptitle || elm.tipgetter();
									tipready && unsafeWindow.tipshow(tip);
								}
							}
							break;
						}
						case 'string': {
							unsafeWindow.tipshow(e);
							break;
						}
					}
				}

				function hidetip() {
					tipready && unsafeWindow.tiphide();
				}
			}
		},

		// AndriodAPI
		{
			name: '安卓API',
			description: '文库安卓API,用于获取网页端没有的资源',
			id: 'AndroidAPI',
			system: true,
			checker: {
				type: 'switch',
				value: false
			},
			func: function() {
				return new AndroidAPI();

				// Android API set
				function AndroidAPI() {
					const AA = this;
					const DParser = new DOMParser();

					const encode = AA.encode = function(str) {
						return '&appver=1.13&request=' + btoa(str) + '&timetoken=' + (new Date().getTime());
					};

					const request = AA.request = function(details) {
						const url = details.url;
						const type = details.type || 'text';
						const callback = details.callback || function() {};
						const args = details.args || [];
						GM_xmlhttpRequest({
							method: 'POST',
							url: 'http://app.wenku8.com/android.php',
							headers: {
								'Content-Type': 'application/x-www-form-urlencoded',
								'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 7.1.2; unknown Build/NZH54D)'
							},
							data: encode(url),
							onload: function(e) {
								let result;
								switch (type) {
									case 'xml':
										result = DParser.parseFromString(e.responseText, 'text/xml');
										break;
									case 'text':
										result = e.responseText;
										break;
								}
								callback.apply(null, [result].concat(args));
							},
							onerror: function(e) {
								Err('Request error while requesting "' + url + '"');
							}
						});
					};

					// aid, lang, callback, args
					AA.getNovelShortInfo = function(details) {
						const aid = details.aid;
						const lang = details.lang;
						const callback = details.callback || function() {};
						const args = details.args || [];
						const url = 'action=book&do=info&aid=' + aid + '&t=' + lang;
						request({
							url: url,
							callback: callback,
							args: args,
							type: 'xml'
						});
					}

					// aid, lang, callback, args
					AA.getNovelIndex = function(details) {
						const aid = details.aid;
						const lang = details.lang;
						const callback = details.callback || function() {};
						const args = details.args || [];
						const url = 'action=book&do=list&aid=' + aid + '&t=' + lang;
						request({
							url: url,
							callback: callback,
							args: args,
							type: 'xml'
						});
					};

					// aid, cid, lang, callback, args
					AA.getNovelContent = function(details) {
						const aid = details.aid;
						const cid = details.cid;
						const lang = details.lang;
						const callback = details.callback || function() {};
						const args = details.args || [];
						const url = 'action=book&do=text&aid=' + aid + '&cid=' + cid + '&t=' + lang;
						request({
							url: url,
							callback: callback,
							args: args,
							type: 'text'
						});
					};

					// Requires user logged in with AndroidAPI, which is not implemented yet
					AA.getUserInfo = function(details) {
						const callback = details.callback || function() {};
						const args = details.args || [];
						const url = 'action=userinfo';
						request({
							url: url,
							callback: callback,
							args: args,
							type: 'xml'
						});
					}
				}
			}
		},

		// Settings
		{
			name: '设置',
			description: '基本组件 - 设置',
			id: 'settings',
			system: true,
			CONST: {
				Number: {
					GFScriptID: 416310,
				},
				Text: {
					SettingsOfUserscript: '脚本自身设置',
					SettingsOfFunctions: '脚本功能设置',
					ScriptVersion: '版本号',
					CheckUpdate: '检查更新',
					ClickMeToCheck: '点我检查更新',
					ExportDebugInfo: '导出调试包',
					ExportConfig: '导出配置',
					ImportConfig: '导入配置',
					ClickMeToExport: '点我导出',
					ClickMeToImport: '点我导入',
					ImportSuccess: '导入成功',
					ImportFailed: '导入失败,错误码: {ErrorCode}',
					Settings: '设置',
					Function: '功能',
					Operation: '操作',
					ShowSystemFuncs: '显示系统组件',
					HideSystemFuncs: '隐藏系统组件',
					SystemFunc: '[系统组件]',
					NonSystemFunc: '[功能模块]',
					DisableFunction: '停用此功能',
					FunctionDisabled: '功能 {FuncName} 已停用',
					EnableFunction: '启用此功能',
					FunctionEnabled: '功能 {FuncName} 已启用',
					FunctionManagement: '管理此功能',
					FunctionSettings: '功能设置',
					RememberSaving: '修改设置/恢复设置后记得点击保存哦:)',
					NoSetting: '此功能没有设置项!',
					FunctionDetail: '<span class="{CT}">名称:</span><span data-key="name"></span></br> <span class="{CT}">描述:</span><span data-key="description"></span></br> <span class="{CT}">ID:</span><span data-key="id"></span></br> <span class="{CT}">系统组件:</span><span data-key="system" use-false-while-empty></span></br> <span class="{CT}">是否已启用:</span><span data-key="enabled"></span></br> ',
					FunctionDetail_EmptyItem: '无',
					Boolean: {
						'true': '是',
						'false': '否'
					},
					ManageWindow: {
						Header: '功能管理',
						EnableTitle: '启用',
						StorageTitle: '存储',
						Save: '保存',
						Reload: '重新载入',
						Export: '导出配置',
						Import: '导入配置',
						Reset: '恢复出厂配置',
						SystemFunc: '[系统组件]',
						NonSystemFunc: '[功能模块]',
						SysAlert: '系统组件的配置仅供查看',
						StorageAlert: '注意:大多数功能的设置都可以通过[功能设置](而不是这里的[管理此功能])进行直观的修改,需要直接在此处进行的手动修改几乎不存在!直接修改功能模块的配置存储很有可能会导致未知的错误,除非你明确的知道你要改什么,否则就不要修改!</br>确定仍然要进行手动修改?',
						StorageSaved: '配置存储已保存',
						StorageEdit: '启用编辑',
						StorageFormatError: '配置存储格式不正确,请检查修正后再保存',
						StorageResetConfirm: '真的要恢复出厂配置吗?</br>如果恢复出厂配置,所有此功能的用户设置都需要重新手动设置。</br>此功能一般用于排除bug,建议您先导出配置进行备份后再恢复出厂配置,并且推荐您在恢复出厂配置后对相关页面先刷新再使用。'
					},
					CheckingUpdate: `${GM_info.script.name}: 正在检查更新...`,
					UpdateFound: {
						Main: `${GM_info.script.name}: 有新版本啦!`,
						View: '[点击 查看 更新]',
						Install: '[点击 安装 更新]'
					},
					UpdateInfo: {
						Header: `${GM_info.script.name} v{version}`,
						Install: '安装',
						Close: '关闭'
					}
				},
				Faicon: {
					Info: 'fa-solid fa-circle-info'
				},
				CSS: {
					Common: '.plus_system:not(.plus_system_show) {display: none;}',
					Manager: '.plus_func_dialog:not(#a) {height: 80vh;min-width: 80vw;}.plus_func_titleblock {display: flex;}.plus_func_icon {height: 50px;}.plus_func_namearea {flex-grow: 1;position: relative;margin-left: 10px;display: flex;flex-direction: column;}.plus_func_nameline {}.plus_func_nameline>* {margin-right: 5px;}.plus_func_name {font-size: 30px;}.plus_func_version {font-size: 20px;}.plus_func_system {font-size: 10px;}.plus_func_descline {font-size: 10px;position: absolute;bottom: 0;}.plus_func_descline>* {margin-right: 3px;}.plus_func_sysalert {margin-top: 5px;color: #f66a13;background-color: #f5eba4;font-size: 15px;line-height: 23px;text-align: center;}.plus_func_block {margin-top: 5px;border-top: 1px solid lightgrey;display: flex;}.plus_func_blocktitle {padding: 1em;text-align: left;vertical-align: top;display: table-cell;width: calc(50px - 2em);}.plus_func_blockcontent {padding: 1em 0;text-align: left;vertical-align: top;display: table-cell;flex-grow: 1;}.plus_func_block [disabled] {cursor: not-allowed;}.plus_func_storageeditor {width: 100%;height: 300px;resize: vertical;}.plus_func_storagebuttons {margin-top: 5px;}.plus_func_storagebuttons>* {margin-right: 10px;padding: 1px 10px;}.plus_func_editlabel {float: right;margin: unset;padding: unset;}.plus_func_editcheckbox {vertical-align: middle;margin-right: 0.3em;}.plus_func_edittext {vertical-align: middle;}'
				},
				Resouces: {
					DefaultIcon: {
						type: 'image',
						value: 'data:image/vnd.microsoft.icon;base64,AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAD/igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//+KAP//igD//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//rlB//3HaP/858P//OfD//znw//858P//OfD//znw//858P//OfD//zitv/91Y///dWP//3Vj//9y3X//cdo//3HaP/9x2j//cdo//zitv/858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//znw//858P//OfD//znw//9x2j//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6cN//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//3ZnP//ogD//6IA//+iAP//ogD//sJb//3HaP/83qn//OfD//zeqf//pw3//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+rGv/90IL//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//cdo//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//OfD//znw//858P//OfD//znw//858P//6IA//+iAP/84rb//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//rQ0//60NP//ogD//6IA//+iAP//ogD//6IA//+iAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//3Vj//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//6wJ///ogD//6sa//3HaP/92Zz//OfD//znw//858P//OfD//znw//858P//OfD//+iAP//ogD//rQ0//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//zitv/+vk7//6IA//+iAP//ogD//6IA//+iAP/+tDT//OfD//znw//858P//OfD//znw//858P//rAn//+iAP//ogD//ct1//3HaP/+tDT//rQ0//6+Tv/858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//ct1//7CW///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw///ogD//6IA//65Qf//ogD//6IA//3HaP/9x2j//6cN//+iAP//ogD//r5O//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//60NP/9x2j//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//+iAP//ogD//OfD//65Qf//ogD//6cN//znw//858P//6IA//+iAP/92Zz//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//6IA//+iAP/858P//cdo//+iAP//ogD//dWP//3Vj///pw3//6IA//7CW//858P//rQ0//+iAP/92Zz//OfD//3Ldf/+tDT//OK2//znw//858P//N6p//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//7CW///ogD//6IA//6+Tv//ogD//6IA//6+Tv/91Y///6IA//+iAP/+vk7//OfD//+iAP//ogD//6IA//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//dWP//+iAP//ogD//6IA//+iAP//ogD//OK2//+rGv//ogD//6IA//3ZnP/858P//6IA//+iAP//qxr//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//sJb//+iAP//pw3//rQ0//+iAP//ogD//OfD//znw//+wlv//6IA//+iAP//ogD//6cN//3ZnP/+vk7//6IA//+iAP/90IL//OfD//60NP//ogD//6IA//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//84rb//rAn//+iAP//ogD//6IA//+iAP/+sCf//rQ0//+iAP//ogD//6IA//+iAP/9x2j//OfD//65Qf//ogD//rAn//znw//858P//rQ0//+iAP/+uUH//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//r5O//60NP//ogD//6IA//+iAP//ogD//6IA//+iAP/9x2j//6IA//+iAP/9x2j//OfD//6+Tv//ogD//6IA//znw//84rb//6IA//+iAP/84rb//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//+nDf//ogD//6cN//60NP/+vk7//ct1//znw//+wlv//6IA//+iAP/858P//OK2//+iAP//ogD//dmc//znw///pw3//6IA//6wJ//83qn//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//cdo//+iAP//ogD//dmc//znw//858P//OfD//znw///pw3//6IA//+nDf/83qn//dmc//+nDf//ogD//6sa//znw//92Zz//6IA//+iAP/858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//N6p//3HaP/+sCf//6IA//+iAP//ogD//6IA//+iAP//qxr//dmc//3ZnP//pw3//6IA//6+Tv/858P//dmc//+iAP//ogD//OfD//znw//9x2j//sJb//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw///ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP/+vk7//OfD//znw//91Y///OK2//znw//858P//dWP//3Vj//9x2j//rlB//+nDf//pw3//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//3Ldf//ogD//6IA//+iAP//ogD//6IA//60NP/+tDT//cdo//zitv/+tDT//rQ0//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP/83qn//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//dmc//+iAP/+sCf//OfD//znw//858P//dWP//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//+iAP//ogD//6IA//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//cdo//zeqf/858P//OfD//znw//858P//rQ0//60NP/+tDT//rQ0//60NP/9x2j//cdo//3HaP/91Y///dWP//zeqf/858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//+KAP/858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//4oA//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw//858P//OfD//znw///igD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
						crossOrigin: false,
					},
				},
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						//'config-key': {},
						lastupdate: '0000-00-00',
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				}
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const SPanel = require('SidePanel');
				const SettingPanel = require('SettingPanel');
				const mousetip = require('mousetip');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				const UFManager = new UserFunctionManager();
				SPanel.insert({
					index: 1,
					faicon: 'fa-solid fa-gear',
					tip: CONST.Text.Settings,
					onclick: UFManager.show
				});

				return {checkUpdate, userCheckUpdate};

				// Module manager user interface
				function UserFunctionManager() {
					const UMM = this;
					const moduleSettingFuncs = {};
					UMM.showing = false;

					initWindow();

					UMM.show = show;
					UMM.showFuncSettings = showFuncSettings;

					function show() {
						//box.set('message', 'No implemented yet!').show();
						if (UMM.showing) {return false;}

						const functions = FL_listFunctions();

						// Make panel
						const SetPanel = new SettingPanel.SettingPanel({
							header: CONST.Text.Settings,
							tables: [],
							onclose: e => UMM.showing = false
						});
						UMM.showing = true;

						// Title - Userscript Settings
						SetPanel.element.appendChild($$CrE({
							tagName: 'h1',
							props: { innerText: CONST.Text.SettingsOfUserscript }
						}));

						SetPanel.appendTable({
							rows: [{
								blocks: [{
									innerText: CONST.Text.ScriptVersion
								}, {
									innerText: `v${GM_info.script.version}`
								}]
							}, {
								blocks: [{
									innerText: CONST.Text.CheckUpdate
								}, {
									innerText: CONST.Text.ClickMeToCheck,
									style: { cursor: 'pointer' },
									listeners: [['click', e => userCheckUpdate()]]
								}]
							}, {
								blocks: [{
									innerText: CONST.Text.ExportDebugInfo
								}, {
									innerText: CONST.Text.ClickMeToExport,
									style: { cursor: 'pointer' },
									listeners: [['click', e => {
										utils.downloadText(JSON.stringify({
											version: GM_info.script.version,
											GM_info: GM_info,
											platform: navigator.platform,
											userAgent: navigator.userAgent,
											time: new Date().getTime(),
											debug: FL_getDebug()
										}), `${GM_info.script.name}-debug.json`);
									}]]
								}]
							}, {
								blocks: [{
									innerText: CONST.Text.ExportConfig
								}, {
									innerText: CONST.Text.ClickMeToExport,
									style: { cursor: 'pointer' },
									listeners: [['click', e => FL_exportConfig()]]
								}]
							}, {
								blocks: [{
									innerText: CONST.Text.ImportConfig
								}, {
									innerText: CONST.Text.ClickMeToImport,
									style: { cursor: 'pointer' },
									listeners: [['click', e => FL_importConfig(err => {
										if (!err) {
											// err === 0
											alertify.success(CONST.Text.ImportSuccess);
										} else {
											// err > 0
											alertify.error(replaceText(CONST.Text.ImportFailed, { '{ErrorCode}': err }));
										}
									})]]
								}]
							}]
						});

						// Title - Function Manager
						SetPanel.element.appendChild($$CrE({
							tagName: 'h1',
							props: { innerText: CONST.Text.SettingsOfFunctions }
						}));

						// Make table
						const table = new SetPanel.PanelTable({});

						// Make header
						table.appendRow({
							blocks: [{
								isHeader: true,
								colSpan: 1,
								width: '70%',
								style: {'text-align': 'center'},
								innerText: CONST.Text.Function,
							},{
								isHeader: true,
								colSpan: 3,
								width: '30%',
								style: {'text-align': 'center'},
								innerText: CONST.Text.Operation,
							}]
						});

						// Make module rows
						// get all funcs
						const funcs = functions.map(id => FL_getFunction(id));
						// insert system funcs into the ending
						funcs.filter(f => f.system).forEach(f => funcs.push(funcs.splice(funcs.indexOf(f), 1)[0]));
						// insert those funcs with settings into the beginning
						funcs.filter(f => f.setting).forEach(f => funcs.splice(funcs.findIndex(f => !f.setting), 0, ...funcs.splice(funcs.indexOf(f), 1)));
						for (const func of funcs) {
							const id = func.id;
							/*
							const btnEnable = makeBtn({
								text: CONST.Text.EnableFunction,
								onclick: function (e) {
									FL_enableFunction(id);
									enableBtn(btnDisable);
									disableBtn(btnEnable);
								},
								disabled: func.enabled,
								alt: CONST.Text.FunctionEnabled
							});
							const btnDisable = makeBtn({
								text: CONST.Text.DisableFunction,
								onclick: function(e) {
									FL_disableFunction(id);
									enableBtn(btnEnable);
									disableBtn(btnDisable);
								},
								disabled: !func.enabled,
								alt: CONST.Text.FunctionDisabled
							});
							*/
							const btnManage = makeBtn({
								text: CONST.Text.FunctionManagement,
								onclick: function(e) {
									return manageFunc(id);
								}
							});
							const btnSetting = makeBtn({
								text: CONST.Text.FunctionSettings,
								onclick: function(e) {
									return showFuncSettings(id) ? 0 : 1;
								},
								disabled: typeof func.setting !== 'function',
								alt: [CONST.Text.RememberSaving, CONST.Text.NoSetting]
							});
							const row = new SetPanel.PanelRow({
								blocks: [
									// Function info
									{
										colSpan: 1,
										rowSpan: 1,
										children: [
											(() => {
												const icon = $CrE('i');
												icon.className = CONST.Faicon.Info;
												icon.style.marginRight = '0.5em';
												icon.classList.add(CommonStyle.ClassName.Text);

												tippy(icon, {
													content: makeContent(),
													theme: 'wenku_tip',
													onTrigger: (instance, event) => {
														instance.setContent(makeContent());
													}
												});
												return icon;

												function makeContent() {
													const func = FL_getFunction(id);
													const tip = $CrE('div');
													tip.innerHTML = replaceText(CONST.Text.FunctionDetail, {'{CT}': CommonStyle.ClassName.TextLight, '{CB}': CommonStyle.ClassName.Button});
													[...tip.children].forEach((elm) => {
														const name = elm.dataset.key;
														if (name) {
															const value = func.hasOwnProperty(name) ? func[name] : (elm.hasAttribute('use-false-while-empty') ? false : CONST.Text.FunctionDetail_EmptyItem);
															elm.innerText = ({
																string: (s) => (s),
																boolean: (b) => (CONST.Text.Boolean[b.toString()]),
															})[typeof value](value);
														}
													});

													return tip;
												}
											}) (),
											(() => {
												const span = $CrE('span');
												span.innerText = func.system ? CONST.Text.SystemFunc : CONST.Text.NonSystemFunc;
												span.style.marginRight = '0.5em';
												return span;
											}) (),
											(() => {
												const span = $CrE('span');
												span.innerText = func.name;
												return span;
											}) (),
										],
									},

									/*
									// Enable function
									{
										colSpan: 1,
										rowSpan: 1,
										width: '15%',
										style: {'text-align': 'center'},
										children: [btnEnable]
									},

									// Diable function
									{
										colSpan: 1,
										rowSpan: 1,
										width: '15%',
										style: {'text-align': 'center'},
										children: [btnDisable]
									},
									*/

									// Module management
									{
										colSpan: 1,
										rowSpan: 1,
										width: '15%',
										style: {'text-align': 'center'},
										children: [btnManage],
									},

									// Module settings
									{
										colSpan: 1,
										rowSpan: 1,
										width: '15%',
										style: {'text-align': 'center'},
										children: [btnSetting]
									}
								]
							});

							// Only show non-system functions by default
							func.system && row.element.classList.add('plus_system');

							// Append to table
							table.appendRow(row);
						}

						// row to show/hide system funcs
						const sysBtn = {
							icon: (() => {
								const icon = $$CrE({
									tagName: 'i',
									classes: ['fa-solid', 'fa-angle-down', CommonStyle.ClassName.Text],
									styles: {
										'margin-right': '0.5em'
									}
								});
								return icon;
							}) (),
							span: (() => {
								const span = $$CrE({
									tagName: 'span',
									props: {
										innerText: CONST.Text.ShowSystemFuncs
									}
								});
								return span;
							}) ()
						}
						const sysrow = new SetPanel.PanelRow({
							blocks: [
								{
									colSpan: 3,
									rowSpan: 1,
									children: [sysBtn.icon, sysBtn.span]
								}
							]
						});
						$AEL(sysrow.element, 'click', (function() {
							let show = false;
							return function(e) {
								// Change icon
								const [rm, add] = show ? ['fa-angle-up', 'fa-angle-down'] : ['fa-angle-down', 'fa-angle-up'];
								sysBtn.icon.classList.remove(rm);
								sysBtn.icon.classList.add(add);

								// Change text
								sysBtn.span.innerText = show ? CONST.Text.ShowSystemFuncs : CONST.Text.HideSystemFuncs;

								// Show / Hide
								[...$All('.plus_system')].forEach(tr => {
									(show ? tr.classList.remove : tr.classList.add).call(tr.classList, 'plus_system_show');
								});

								// Reverse flag
								show = !show;
							}
						}) ());
						sysrow.element.classList.add(CommonStyle.ClassName.Button);
						table.appendRow(sysrow);

						// Append table
						SetPanel.appendTable(table);

						function makeBtn(details) {
							// Get arguments
							let text, onclick, disabled, alt;
							text = details.text;
							onclick = details.onclick;
							disabled = details.disabled;
							alt = details.alt;

							const span = $CrE('span');
							span.innerText = text;
							onclick && span.addEventListener('click', _onclick);
							span.classList.add(CommonStyle.ClassName.Button);
							disabled && span.classList.add(CommonStyle.ClassName.Disabled);
							span.disabled = disabled;

							return span;

							function _onclick() {
								const disabled = span.disabled;
								const result = !disabled && onclick ? onclick.call(this, arguments) : 0;
								const alt_content = alt && (Array.isArray(alt) ? alt[result] : alt);
								!disabled && ![null, false].includes(result) && alt_content && alertify.message(alt_content);
							}
						}

						function disableBtn(span) {
							span.disabled = true;
							span.classList.add(CommonStyle.ClassName.Disabled);
						}

						function enableBtn(span) {
							span.disabled = false;
							span.classList.remove(CommonStyle.ClassName.Disabled);
						}
					}

					function showFuncSettings(id) {
						const setter = FL_getFunction(id).setting;
						if (typeof setter === 'function') {
							FL_loadSetting(id);
							return true;
						} else {
							return false;
						}
					}

					function manageFunc(idorfunc) {
						// Make panel
						//alertify.alert('not implemented yet!');
						const [id, func] = isWenkuFunction(idorfunc) ? [idorfunc.id, idorfunc] : [idorfunc, FL_getFunction(idorfunc)];
						alertify.funcmanager(func);
					}

					function initWindow() {
						addStyle(CONST.CSS.Common, 'plus-settings-common');
						addStyle(CONST.CSS.Manager, 'plus-settings-manager');
						/*!alertify.installer && alertify.dialog('installer', function() {
							return{
								main: function(){
									let content, oninstall, oncancel;
									switch (arguments.length) {
										case 1: {
											const val = arguments[0];
											if (typeof val === 'object' && !(val instanceof HTMLElement)) {
												content = val.content;
												oninstall = val.oninstall;
												oncancel = val.oncancel;
											} else if (typeof val === 'string' || val instanceof HTMLElement) {
												[content] = arguments;
											} else {
												Err('Invalid argument type', 1);
											}
											break;
										}
										case 2:
											[content, oninstall] = arguments;
											break;
										case 3:
											[content, oninstall, oncancel] = arguments;
											break;
										default:
											Err('Invalid argument length', 1);
									}
									this.set('content', content);
									this.set('oninstall', oninstall);
									this.set('oncancel', oncancel);
								},
								setup: function(){
									return {
										buttons:[{
											text: CONST.Text.Cancel,
											scope: 'auxiliary',
											action: 'cancel', // This is a custom attribute
											key: 27 // Esc
										}, {
											text: CONST.Text.InstallModule,
											scope: 'primary',
											action: 'install', // This is a custom attribute
											key: 65 // Enter
										}],
										focus: {element: 0}
									};
								},
								prepare: function(){
									this.setContent(this.get('content'));
								},
								callback: function(closeEvent) {
									const listenerName = 'on' + closeEvent.button.action;
									const listener = this.get(listenerName);
									return typeof listener === 'function' ? listener(closeEvent) : true;
								},
								settings: {
									content: undefined,
									oninstall: undefined,
									oncancel: undefined
								}
							}
						}, true);*/
						/* 窗口设计
						参照Tampermonky设置界面,每一个大功能设置分类就一个块,从上到下罗列开来
						--------------------
						||图标|  名称.版本  ||
						||图标|    描述    ||
						--------------------
						| 启用 | [ ]        |
						--------------------
						|     |------------|
						| 存  ||    JS    ||
						| 储  ||    ON    ||
						|     |------------|
						--------------------

						*/
						!alertify.funcmanager && alertify.dialog('funcmanager', function() {
							return {
								// Accept every alertify.funcmanager call
								main: function(func) {
									const elements = this.elements;
									this.func = func;

									// Icon
									const icon = func.icon || {type: 'image', value: GM_info.script.icon};
									switch (icon.type) {
										case 'image': {
											const img = $$CrE({
												tagName: 'img',
												props: {
													src: icon.value,
												},
												classes: 'plus_func_icon'
											});
											[...elements.iconarea.children].forEach(n => n.remove())
											elements.iconarea.appendChild(img);
											break;
										}
										case 'faicon': {
											const i = $$CrE({
												tagName: 'i',
												classes: icon.value
											});
											[...elements.iconarea.children].forEach(n => n.remove())
											elements.iconarea.appendChild(i);
										}
									}

									// Name
									elements.name.innerText = func.name;

									// System
									elements.system.innerText = func.system ? CONST.Text.ManageWindow.SystemFunc : CONST.Text.ManageWindow.NonSystemFunc;

									// Version
									elements.version.innerText = `v${func.version || '0.1'}`;

									// Description
									elements.description.innerText = func.description || `功能:${func.name}`;

									// System alert
									elements.sysalert.style.display = func.system ? '' : 'none';

									// Enable
									const enable = elements.enablecheckbox;
									enable.checked = func.enabled;
									enable.disabled = func.system;

									// Storage
									const editor = elements.storageeditor;
									editor.value = vkbeautify.json(func.storage, 2);
									editor.disabled = true;

									// Stoarge buttons
									elements.storagesave.disabled = true;
									elements.storagereload.disabled = false;
									elements.storageexport.disabled = false;
									elements.storageimport.disabled = true;
									elements.storageedit.checked = false;
									elements.storageedit.disabled = func.system;
									elements.storageeditlabel.style.display = func.system ? 'none' : '';
								},

								// Custom buttons
								setup: function() {
									return {
										buttons: [{
											text: '确认',
											key: 27, // esc, see https://developer.mozilla.org/zh-CN/docs/Web/API/KeyboardEvent/keyCode#%E9%94%AE%E7%A0%81%E5%80%BC
											invokeOnClose: true,
											className: alertify.defaults.theme.ok,
											scope: 'primary'
										}],
										focus: {
											element: 0,
											select: true
										}
									}
								},

								// Build standard function manager dom structure
								build: function() {
									const _this = this;
									const elements = this.elements;
									const container = elements.content;

									// Dialog
									elements.dialog.classList.add('plus_func_dialog');

									// Header
									elements.header.innerText = CONST.Text.ManageWindow.Header;

									// Title area
									const titleblock = elements.titleblock = $$CrE({
										tagName: 'div',
										styles: {
											'border-width': '5px 0 0 0',
										}
									});
									titleblock.classList.add('plus_func_titleblock');
									container.appendChild(titleblock);

									// Icon
									const iconarea = elements.iconarea = $CrE('div');
									titleblock.appendChild(iconarea);

									// Name area
									const namearea = elements.namearea = $CrE('div');
									namearea.classList.add('plus_func_namearea');
									titleblock.appendChild(namearea);

									// Name line
									const nameline = $CrE('div');
									nameline.classList.add('plus_func_nameline');
									namearea.appendChild(nameline);

									// Name
									const name = elements.name = $CrE('span');
									name.classList.add('plus_func_name');
									nameline.appendChild(name);

									// Version
									const version = elements.version = $CrE('span');
									version.classList.add('plus_func_version');
									nameline.appendChild(version);

									// Description line
									const descline = $CrE('div');
									descline.classList.add('plus_func_descline');
									namearea.appendChild(descline);

									// System
									const system = elements.system = $CrE('span');
									system.classList.add('plus_func_system');
									descline.appendChild(system);

									// Description
									const description = elements.description = $CrE('span');
									description.classList.add('plus_func_description');
									descline.appendChild(description);

									// System alert
									const sysalert = elements.sysalert = $CrE('div');
									sysalert.innerText = CONST.Text.ManageWindow.SysAlert;
									sysalert.classList.add('plus_func_sysalert');
									container.appendChild(sysalert);

									// Enable block
									const enableblock = elements.enableblock = $CrE('div');
									enableblock.classList.add('plus_func_block');
									container.appendChild(enableblock);

									// Enable title
									const enabletitle = elements.enabletitle = $$CrE({
										tagName: 'div',
										props: {
											innerText: CONST.Text.ManageWindow.EnableTitle
										},
										classes: 'plus_func_blocktitle'
									});
									enableblock.appendChild(enabletitle);

									// Enable centent
									const enablecontent = elements.enablecontent = $$CrE({
										tagName: 'div',
										classes: 'plus_func_blockcontent'
									});
									enableblock.appendChild(enablecontent);

									// Enable checkbox
									const enablecheckbox = elements.enablecheckbox = $$CrE({
										tagName: 'input',
										props: {
											type: 'checkbox',
										},
										classes: 'plus_func_enablecheckbox',
										listeners: [['change', function(e) {
											_this.func.enabled = enablecheckbox.checked;
											alertify.success(replaceText(enablecheckbox.checked ? CONST.Text.FunctionEnabled : CONST.Text.FunctionDisabled, {'{FuncName}': _this.func.name}));
										}]]
									});
									enablecontent.appendChild(enablecheckbox);

									// Storage block
									const storageblock = elements.storageblock = $CrE('div');
									storageblock.classList.add('plus_func_block');
									container.appendChild(storageblock);

									// Storage title
									const storagetitle = elements.storagetitle = $$CrE({
										tagName: 'div',
										props: {
											innerText: CONST.Text.ManageWindow.StorageTitle
										},
										classes: 'plus_func_blocktitle'
									});
									storageblock.appendChild(storagetitle);

									// Storage content
									const storagecontent = elements.storagecontent = $$CrE({
										tagName: 'div',
										classes: 'plus_func_blockcontent',
										/*listeners: [['click', function(e) {
											// storageeditor cannot listen mouse click events while it's disabled
											if (e.target === storageeditor && storageeditor.disabled && !_this.func.system) {
												alertify.confirm(CONST.Text.ManageWindow.Header, CONST.Text.ManageWindow.StorageAlert, function() {
													storageeditor.disabled = false;
													//storageeditor.focus() // Do not focus directly, no focus will gain!
													setTimeout(e => storageeditor.focus(), 0); // focus with a setTimeout is okay
													storageedit.checked = true;
												}, e => {});
											}
										}]]*/
									});
									storageblock.appendChild(storagecontent);

									// Storage editor
									const storageeditor = elements.storageeditor = $$CrE({
										tagName: 'textarea',
										prop: {
											disabled: true
										},
										classes: 'plus_func_storageeditor'
									});
									storagecontent.appendChild(storageeditor);

									// Storage buttons
									const storagebuttons = elements.storagebuttons = $$CrE({
										tagName: 'div',
										classes: 'plus_func_storagebuttons'
									});
									storagecontent.appendChild(storagebuttons);

									// Storage save button
									const storagesave = elements.storagesave = $$CrE({
										tagName: 'button',
										props: {
											innerText: CONST.Text.ManageWindow.Save
										},
										classes: 'plus_func_storagesave',
										listeners: [['click', function(e) {
											try {
												const storage = JSON.parse(storageeditor.value);
												Object.keys(_this.func.storage).forEach(k => delete _this.func.storage[k])
												copyProps(storage, _this.func.storage);
												alertify.success(CONST.Text.ManageWindow.StorageSaved);
											} catch(err) {
												alertify.error(CONST.Text.ManageWindow.StorageFormatError);
											}
										}]]
									});
									storagebuttons.appendChild(storagesave);

									// Storage reload button
									const storagereload = elements.storagereload = $$CrE({
										tagName: 'button',
										props: {
											innerText: CONST.Text.ManageWindow.Reload
										},
										classes: 'plus_func_storagereload',
										listeners: [['click', function(e) {
											storageeditor.value = vkbeautify.json(_this.func.storage, 2);
										}]]
									});
									storagebuttons.appendChild(storagereload);

									// Storage export button
									const storageexport = elements.storageexport = $$CrE({
										tagName: 'button',
										props: {
											innerText: CONST.Text.ManageWindow.Export
										},
										classes: 'plus_func_storagereload',
										listeners: [['click', function(e) {
											utils.downloadText(JSON.stringify(_this.func.storage), `${GM_info.script.name}-${_this.func.id}-config.json`);
											storageeditor.value = vkbeautify.json(_this.func.storage, 2);
										}]]
									});
									storagebuttons.appendChild(storageexport);

									// Storage import button
									const storageimport = elements.storageimport = $$CrE({
										tagName: 'button',
										props: {
											innerText: CONST.Text.ManageWindow.Import
										},
										classes: 'plus_func_storagereload',
										listeners: [['click', function(e) {
											if (!storageeditor.disabled) {
												const input = $$CrE({
													tagName: 'input',
													props: { type: 'file' },
													listeners: [['change', e => {
														try {
															const file = input.files[0];
															const reader = new FileReader();
															reader.onload = e => {
																_this.func.storage = JSON.parse(reader.result);
																storageeditor.value = vkbeautify.json(_this.func.storage, 2);
															}
															reader.onerror = err => { throw err; };
															reader.readAsText(file);
														} catch(err) {
															DoLog(LogLevel.Warning, 'Import error');
															DoLog(LogLevel.Warning, err, 'warn');
														}
													}]]
												});
												input.click();
											}
											/*
											utils.downloadText(JSON.stringify(_this.func.storage), `${GM_info.script.name}-${_this.func.id}-config.json`);
											storageeditor.value = vkbeautify.json(_this.func.storage, 2);
											*/
										}]]
									});
									storagebuttons.appendChild(storageimport);

									const storagereset = elements.storagereset = $$CrE({
										tagName: 'button',
										props: {
											innerText: CONST.Text.ManageWindow.Reset
										},
										classes: 'plus_func_storagereset',
										listeners: [['click', function(e) {
											alertify.confirm(CONST.Text.ManageWindow.Header, CONST.Text.ManageWindow.StorageResetConfirm, function() {
												_this.func.storage = {};
												storageeditor.value = vkbeautify.json(_this.func.storage, 2);
											}, e => {});
										}]]
									});
									storagebuttons.appendChild(storagereset);

									// Storage edit checkbox
									const storageedit = elements.storageedit = $$CrE({
										tagName: 'input',
										props: {
											type: 'checkbox'
										},
										classes: 'plus_func_editcheckbox',
										listeners: [['change', (function() {
											let firstChecked = false;
											return function(e) {
												if (_this.func.system) {
													storageedit.checked = false;
													return;
												}
												if (storageedit.checked) {
													if (!firstChecked) {
														alertify.confirm(CONST.Text.ManageWindow.Header, CONST.Text.ManageWindow.StorageAlert, function() {
															firstChecked = true;
															enable();
														}, e => { storageedit.checked = false; });
													} else {
														enable();
													}
												} else {
													disable();
												}

												function disable() {
													[storagesave, storageimport, storageeditor].forEach(btn => btn.disabled = true);
												}

												function enable() {
													[storagesave, storageimport, storageeditor].forEach(btn => btn.disabled = false);
													//storageeditor.focus() // Do not focus directly, no focus will gain!
													setTimeout(e => storageeditor.focus(), 0); // focus with a setTimeout is okay
												}
											};
										})()]]
									});
									const storageedittext = elements.storageedittext = $$CrE({
										tagName: 'span',
										classes: 'plus_func_edittext',
										props: { innerText: CONST.Text.ManageWindow.StorageEdit }
									});
									const storageeditlabel = elements.storageeditlabel = $$CrE({
										tagName: 'label',
										classes: 'plus_func_editlabel',
									});
									storageeditlabel.appendChild(storageedit);
									storageeditlabel.appendChild(storageedittext);
									storagebuttons.appendChild(storageeditlabel);
								}
							}
						});
					}
				}

				// Check userscript update and returns a Promise({info, hasUpdate})
				async function checkUpdate(autoInstall=true) {
					const GF = new GreasyFork();

					alertify.message(CONST.Text.CheckingUpdate);
					const info = await GF.get().script().info(CONST.Number.GFScriptID);
					CONFIG.lastupdate = utils.getTime('-', false);

					if (versionNewer(info.version, GM_info.script.version)) {
						autoInstall && info.action('install', { id: CONST.Number.GFScriptID });
						return {info, hasUpdate: true};
					} else {
						return {info, hasUpdate: false};
					}

					function versionNewer(v1, v2) {
						if ([v1, v2].some(v => !/(\d\.?)+/.test(v))) {
							Err('versionNewer: all version strings should only contain number and dot(.), and have no consecutive dots.');
						}

						[v1, v2] = [v1, v2].map(v => v.split('.').map(s => parseInt(s, 10)));
						for (let i = 0; i < Math.min(v1.length, v2.length); i++) {
							if (v1[i] > v2[i]) {
								return true;
							}
						}
						if (v1.length > v2.length) {
							return true;
						}
						return false;
					}
				}

				// Check userscript update and provide update GUI for user if update exists
				// Returns a Promise(statusNumber), statusNumber = -1: User refuse to update; 0: No update found; 1: User clicked 'install' button in GUI
				async function userCheckUpdate() {
					checkUpdate(false).then(update => new Promise((resolve, reject) => {
						const GF = new GreasyFork();
						if (!update.hasUpdate) {
							resolve(0);
						}

						const message = $$CrE({ tagName: 'div', props: { innerText: CONST.Text.UpdateFound.Main } });
						const btnView = $$CrE({
							tagName: 'span',
							props: { innerText: CONST.Text.UpdateFound.View },
							classes: [CommonStyle.ClassName.Button],
							listeners: [['click', view]]
						});
						const btnInstall = $$CrE({
							tagName: 'span',
							props: { innerText: CONST.Text.UpdateFound.Install },
							classes: [CommonStyle.ClassName.Button],
							listeners: [['click', install]]
						});
						[$CrE('br'), btnView, $CrE('br'), btnInstall].forEach(elm => message.appendChild(elm));
						alertify.message(message);

						function view() {
							console.log('autoupdate: view update');

							const dp = new DOMParser();
							const doc = dp.parseFromString(update.info.additionalInfo[0].html, 'text/html');
							const updateInfo = $(doc, 'div[name="update-info"]');
							const box = alertify.confirm(updateInfo ? updateInfo : doc.body, install, e => resolve(-1));
							box.setHeader(replaceText(CONST.Text.UpdateInfo.Header, {'{version}': update.info.version}));
							box.set('labels', {ok: CONST.Text.UpdateInfo.Install, cancel: CONST.Text.UpdateInfo.Close});
							box.set('overflow', true);
						}

						function install() {
							GF.action('install', { id: CONST.Number.GFScriptID });
							resolve(1);
						}
					}));
				}
			},
			alwaysRun: function() {
				const settings = require('settings');
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');
				const GF = new GreasyFork();
				const CONST = FuncInfo.CONST;

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				!todayUpdated() && settings.userCheckUpdate();

				function todayUpdated() {
					return utils.getTime('-', false) === CONFIG.lastupdate;
				}
			}
		},

		// WenkuBlockGUI
		{
			name: 'WenkuBlockGUI',
			description: '基本组件 - 提供文库风格的界面元素支持',
			id: 'WenkuBlockGUI',
			system: true,
			checker: {
				type: 'switch',
				value: false
			},
			func: function() {
				const mousetip = require('mousetip');
				const CONST = {
					Text: {
						DefaultTitle: '操作区域'
					},
					CSS: {
						ClassName: {
							List: 'plus_list',
							ListButton: 'plus_list_input',
							ListItem: 'plus_list_item'
						},
						Style: {
							PlusList: '.plus_list>ul {list-style: none; text-align: center; padding: 0px; margin: 0px;} .plus_list {position: absolute; z-index: 999; background-color: #f5f5f5; float: left; clear: both; overflow-y: auto; overflow-x: visible;} .plus_list_input {display: block; list-style: outside none none; margin: 0px; border: 0;} .plus_list_item {border: 0px; cursor: pointer; padding: 0 0.5em; border: 1px solid rgb(204, 204, 204); background-color: buttonface;}',
						}
					}
				};

				Object.keys(CONST.CSS.Style).forEach(key => addStyle(CONST.CSS.Style[key], key));

				return {
					makeStandardBlock,
					makeBookcaseBlock,
					makeIndexCenterToplistBlock,
					makeIndexRightToplistBlock,
					PlusList
				};

				// Create a standard block structure
				function makeStandardBlock() {
					const block = $$CrE({tagName: 'div', classes: ['block']});
					const blocktitle = $$CrE({tagName: 'div', classes: ['blocktitle']});
					const blockcontent = $$CrE({tagName: 'div', classes: ['blockcontent']});
					block.appendChild(blocktitle);
					block.appendChild(blockcontent);
					return {block, blocktitle, blockcontent};
				}

				// Bookcase-left block
				function makeBookcaseBlock(detail) {
					const block = makeStandardBlock();

					// Title
					block.blocktitle.appendChild($$CrE({
						tagName: 'span',
						props: {'innerText': detail.title || ''},
						classes: ['txt']
					}));
					block.blocktitle.appendChild($$CrE({
						tagName: 'span',
						classes: ['txtr']
					}));

					// Content links
					const ul = $$CrE({
						tagName: 'ul',
						classes: ['ulitem']
					});
					for (const objLink of detail.links || []) {
						const li = $CrE('li');
						const a = $$CrE({
							tagName: 'a',
							props: {
								'href': objLink.href || 'javascript: void(0);',
								'innerText': objLink.text || '',
								...objLink.props || {}
							},
							styles:  objLink.styles || {},
							classes: objLink.classes || [],
							listeners: objLink.listeners || []
						});
						objLink.hasOwnProperty('key') && (block[objLink.key] = a);
						li.appendChild(a);
						ul.appendChild(li);
					}
					block.blockcontent.appendChild(ul);

					return block;
				}

				// Index page center books toplist block
				// '本周会员推荐榜'
				function makeIndexCenterToplistBlock(detail) {
					const block = makeStandardBlock();

					// Title
					block.blocktitle.innerText = detail.title || '';

					// Content books
					const container = $$CrE({tagName: 'div', styles: {'height': '155px'}});
					block.blockcontent.appendChild(container);
					block.items = [];
					for (const book of detail.books) {
						const b_container = $$CrE({
							tagName: 'div',
							styles: {
								cssText: 'float: left; text-align: center; width: 95px; height: 155px; overflow: hidden;'
							}
						});
						container.appendChild(b_container);

						const imgLink = $$CrE({
							tagName: 'a',
							props: {
								href: book.url,
								target: '_blank'
							}
						});
						mousetip.settip(imgLink, book.name);
						b_container.appendChild(imgLink);

						const img = $$CrE({
							tagName: 'img',
							props: {src: book.cover},
							attrs: {
								border: 0,
								width: 90,
								height: 127
							}
						});
						imgLink.appendChild(img);

						b_container.appendChild($CrE('br'));

						const nameLink = $$CrE({
							tagName: 'a',
							props: {
								innerText: book.name,
								href: book.url,
								target: '_blank'
							}
						});
						b_container.appendChild(nameLink);

						block.items.push({ ...book, b_container, imgLink, img, nameLink });
					}

					return block;
				}

				// '最受关注'
				function makeIndexRightToplistBlock(detail) {
					const block = makeStandardBlock();

					// Title
					block.blocktitle.appendChild($$CrE({
						tagName: 'span',
						props: {
							innerText: detail.title || '',
						},
						classes: ['txt']
					}));
					block.blocktitle.appendChild($$CrE({
						tagName: 'span',
						classes: ['txtr']
					}));

					// Content
					const ul = $$CrE({
						tagName: 'ul',
						classes: ['ultop']
					});
					block.blockcontent.appendChild(ul);

					block.items = [];
					for (const link of detail.links) {
						const li = $$CrE({
							tagName: 'li',
							styles: {
								overflow: 'hidden'
							}
						});
						const a = $$CrE({
							tagName: 'a',
							props: {
								innerText: link.text,
								href: link.url,
								target: '_blank',
							}
						});
						mousetip.settip(a, link.text);
						li.appendChild(a);
						ul.appendChild(li);
						block.items.push({ ...link, li, a });
					}

					return block;
				}

				// Create a list gui like reviewshow.php##FontSizeTable
				// list = {display: '', id: '', parentElement: <*>, insertBefore: <*>, list: [{value: '', onclick: Function, tip: ''/Function}, ...], visible: bool, onshow: Function(bool shown), onhide: Function(bool hidden)}
				// structure: {div: <div>, ul: <ul>, list: [{li: <li>, button: <input>}, ...], visible: list.visible, show: Function, hide: Function, append: Function({...}), remove: Function(index), clear: Function, onshow: list.onshow, onhide: list.onhide}
				// Use 'new' keyword
				function PlusList(list) {
					const PL = this;

					// Make list
					const div = PL.div = document.createElement('div');
					const ul = PL.ul = document.createElement('ul');
					div.classList.add(CONST.CSS.ClassName.List);
					div.appendChild(ul);
					list.display && (div.style.display = list.display);
					list.id && (div.id = list.id);
					list.parentElement && list.parentElement.insertBefore(div, list.insertBefore ? list.insertBefore : null);

					PL.list = [];
					for (const item of list.list) {
						appendItem(item);
					}

					// Attach properties
					let onshow = list.onshow ? list.onshow : function() {};
					let onhide = list.onhide ? list.onhide : function() {};
					let visible = list.visible;
					let maxheight;
					PL.create = createItem;
					PL.append = appendItem;
					PL.insert = insertItem;
					PL.remove = removeItem;
					PL.clear = removeAll;
					PL.show = showList;
					PL.hide = hideList;
					Object.defineProperty(PL, 'onshow', {
						get: function() {return onshow;},
						set: function(func) {
							onshow = func ? func : function() {};
						},
						configurable: false,
						enumerable: true
					});
					Object.defineProperty(PL, 'onhide', {
						get: function() {return onhide;},
						set: function(func) {
							onhide = func ? func : function() {};
						},
						configurable: false,
						enumerable: true
					});
					Object.defineProperty(PL, 'visible', {
						get: function() {return visible;},
						set: function(bool) {
							if (typeof(bool) !== 'boolean') {return false;};
							visible = bool;
							bool ? showList() : hideList();
						},
						configurable: false,
						enumerable: true
					});
					Object.defineProperty(PL, 'maxheight', {
						get: function() {return maxheight;},
						set: function(num) {
							if (typeof(num) !== 'number') {return false;};
							maxheight = num;
						},
						configurable: false,
						enumerable: true
					});

					// Apply configurations
					div.style.display = list.visible === true ? '' : 'none';

					// Functions
					function appendItem(item) {
						const listitem = createItem(item);
						ul.appendChild(listitem.li);
						PL.list.push(listitem);
						return listitem;
					}

					function insertItem(item, index, insertByNode=false) {
						const listitem = createItem(item);
						const children = insertByNode ? ul.childNodes : ul.children;
						const elmafter = children[index];
						ul.insertBefore(item.li, elmafter);
						inserttoarr(PL.list, listitem, index);
					}

					function createItem(item) {
						const listitem = {
							remove: () => {removeItem(listitem);},
							li: document.createElement('li'),
							button: document.createElement('input')
						};
						const li  = listitem.li;
						const btn = listitem.button;
						btn.type = 'button';
						btn.classList.add(CONST.CSS.ClassName.ListButton);
						li.classList.add(CONST.CSS.ClassName.ListItem);
						item.value && (btn.value = item.value);
						item.onclick && btn.addEventListener('click', item.onclick);
						item.tip && mousetip.settip(li, item.tip);
						item.tip && mousetip.settip(btn, item.tip);
						li.appendChild(btn);
						return listitem;
					}

					function removeItem(itemorindex) {
						// Get index
						let index;
						if (typeof(itemorindex) === 'number') {
							index = itemorindex;
						} else if (typeof(itemorindex) === 'object') {
							index = PL.list.indexOf(itemorindex);
						} else {
							return false;
						}
						if (index < 0 || index >= PL.list.length) {
							return false;
						}

						// Remove
						const li = PL.list[index];
						ul.removeChild(li.li);
						delfromarr(PL.list, index);
						return li;
					}

					function removeAll() {
						const length = PL.list.length;
						for (let i = 0; i < length; i++) {
							removeItem(0);
						}
					}

					function showList() {
						if (visible) {return false;};
						onshow(false);
						div.style.display = '';
						onshow(true);
						visible = true;
					}

					function hideList() {
						if (!visible) {return false;};
						onhide(false);
						div.style.display = 'none';
						mousetip.hidetip();
						onhide(true);
						visible = false;
					}

					// Support functions
					// Del an item from an array by provided index, returns the deleted item. MODIFIES the original array directly!!
					function delfromarr(arr, delIndex) {
						if (delIndex < 0 || delIndex > arr.length-1) {
							return false;
						}
						const deleted = arr[delIndex];
						for (let i = delIndex; i < arr.length-1; i++) {
							arr[i] = arr[i+1];
						}
						arr.pop();
						return deleted;
					}

					// Insert an item to an array by its provided index, returns the item itself. MODIFIES the original array directly!!
					function inserttoarr(arr, item, index) {
						if (index < 0 || index > arr.length-1) {
							return false;
						}
						for (let i = arr.length; i > index; i--) {
							arr[i] = arr[i-1];
						}
						arr[index] = item;
						return item;
					}
				}
			}
		},

		// Imager
		{
			name: '图床',
			description: '提供图床上传图片接口,方便用户上传图片',
			id: 'imager',
			system: true,
			checker: {
				type: 'switch',
				value: true
			},
			STOP: false,
			CONST: {
				Text: {
					/*ImageOnly: '您提供的文件无法被识别为图片',
					ImageUploadError: '图片上传失败',
					ClicktoPaste: '点击需要粘贴图片的位置,或者点击其他位置/esc取消',*/
					StatusText: {
						empty: '拖放图片到这里或者单击选择图片',
						waiting: '等待上传',
						uploading: '正在上传',
						uploaded: '上传成功',
						error: '上传失败'
					},
					ScriptTitle: GM_info.script.name,
					OneImageOnly: '每次请选择仅一张图片',
					ImageOnly: '您提供的文件并没有可识别的图片格式</br>请尝试jpg、jpeg、png等常用格式的图片',
					ConnectRequired_Title: '需要网络权限',
					ConnectRequired: `${GM_info.script.name}在为您上传图片时出现了错误</br>原因:缺乏网络权限</br></br>您需要允许对图床网站的网络访问权限,${GM_info.script.name}才能够使用此图片或者访问图床</br></br>您可以在弹出的Tampermonkey页面中点击“允许一次”或者“临时允许”来临时允许${GM_info.script.name}访问此图片或者访问图床,或者点击“总是允许全部域名”永久允许${GM_info.script.name}访问您提供的图片或图床以避免以后再次弹出确认`,
					UploadError: '图片上传失败',
					CoverText: '拖放上传图片',
					UploaderHoverText: '拖放上传图片 或 单击选择图片',
					ClickToClear: '清除已上传图片'
				},
				Imagers: [{
					id: 'p.sda1.dev',
					upload: {
						request: {
							url: 'https://p.sda1.dev/api_dup$backendid$/v1/upload_noform?ts=$time$&rand=$random$&filename=$filename$&batch_size=1',
							replacers: {
								'$backendid$': () => parseInt(Math.random() * 1000000007) % 10
							},
							data: {
								type: 'file'
							},
							responseType: 'json',
							onerror: (err, callback) => GM_xmlhttpRequest({
								method: 'GET',
								url: 'https://p.sda1.dev/assets/css/thumb.css',
								timeout: 5 * 1000,
								onload: err => callback(true),
								onerror: err => callback(false),
								ontimeout: err => callback(false),
							})
						},
						response: {
							type: 'func',
							func: response => new URL(response.response.data.url).href,
							valid: response => typeof response.response === 'object' && response.response !== null && response.response.data && response.response.data.url
						}
					},
					features: ['custom-filename', 'custom-extention']
				}, {
					id: 'sougou',
					upload: {
						request: {
							url: 'https://image.sogou.com/pic/upload_pic.jsp',
							headers: {
								//'Content-Type': 'multipart/form-data' // NO this header manually! this will overwrite Content-Type with Boundary value missing
							},
							data: {
								type: 'form',
								value: 'pic_path'
							}
						},
						response: {
							type: 'url',
							valid: response => typeof response.response === 'string' && /https?:\/\//.test(response.response),
						}
					},
				}],
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						//'config-key': {},
						stat: {}
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				}
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const SettingPanel = require('SettingPanel');
				const mousetip = require('mousetip');

				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				const UPLOADER = new Uploader();

				// <image-selector> custom element
				registerElement();

				SettingPanel.registerElement('image', {
					createElement: function() {
						const uploader = $CrE('image-uploader');
						$AEL(uploader, 'error', err => {
							alertify.error(CONST.Text.UploadError);
						});
						return uploader;
					},
					setValue: function(val) {
						// when val accepted, it means we're reading an uploaded url from storage
						// So val is url
						this.element.url = val;
					},
					getValue: function() {
						return this.element.url;
					},
				});

				return {upload};

				function upload() {
					UPLOADER.upload.apply(this, arguments);
				}

				// Image uploader
				/*
					UPLOADER.upload(file, onload, onerror=e=>{}, retry=3)
						onload => onload(url)
						onerror => onerror()
					UPLOADER.uploadByImager(file, imager, onload, onerror)
						onload => onload(url)
						onerror => onerror()
				*/
				function Uploader() {
					const UP = this;
					UP.upload = upload;
					UP.uploadByImager = uploadByImager;
					init();

					function upload(file, onload, onerror=e=>{}, features=[]) {
						const imager_ids = getSortedImagers(features);
						let index = 0, retry = 3, imagerRetried = imager_ids.map(i => false);
						uploadByImager(file, imager_ids[0], onload, autoRetry);

						function autoRetry(err) {
							const imager = CONST.Imagers.find(i => i.id === imager_ids[index]);
							if (isConnectErr(err)) {
								// @connect not allowed
								onerror();
								return false;
							} else if (imager.upload.request.onerror && !imagerRetried[index]) {
								let timeouted = false, retrying = false;
								imagerRetried[index] = true;

								// Imager has an error handler, handle the error
								// and give it a chance to retry before change to another imager
								imager.upload.request.onerror(err, success => {
									if (success && !timeouted) {
										retrying = true;
										uploadByImager(file, imager_ids[index], onload, autoRetry)
									} else {
										autoRetry();
									}
								});

								// We give imager's error handler 5 seconds to finish its task
								// If it doesn't finish its task in time, just go ahead
								setTimeout(e => {
									if (!retrying) {
										timeouted = true;
										autoRetry();
									}
								}, 5* 1000);
							} else if (--retry > 0 && ++index < imager_ids.length) {
								uploadByImager(file, imager_ids[index], onload, autoRetry);
							} else {
								onerror();
							}
						}
					}

					function uploadByImager(file, imager_id, onload, onerror) {
						const imager = CONST.Imagers.find(i => i.id === imager_id);
						const req = imager.upload.request;
						const res = imager.upload.response;

						const headers = req.headers || {};
						const responseType = req.responseType || 'text';
						const data = ({
							'file': () => file,
							'form': () => {
								const formdata = new FormData();
								formdata.set(req.data.value, file);
								return formdata;
							},
							checker: url => !!url
						})[req.data.type]();

						// Construct request url
						const url = makeUrl(req.url, req.replacers);

						// Request
						GM_xmlhttpRequest({
							method: 'POST',
							url: url,
							headers: {...headers}, // headers should be object, use {...headers} insteadof headers to validate
							responseType, data,
							onerror: handleError,
							ontimeout: handleError,
							onload: dealResponse
						});

						// Response
						function dealResponse(response) {
							if (!res.valid(response)) {
								handleError(response);
								return false;
							}

							let url = ({
								'url': response => response.responseText,
								'func': response => res.func(response)
							})[res.type](response);
							url = new URL(url).href;

							record(imager_id, true);

							onload(url);
						}

						function handleError(err) {
							isConnectErr(err) ?
								alertify.alert(CONST.Text.ConnectRequired_Title, CONST.Text.ConnectRequired) :
								record(imager_id, false);

							onerror.apply(this, arguments);
						}

						function makeUrl(url, _replacers={}) {
							const replacers = {
								'$filename$': () => (encodeURIComponent(file.name)),
								'$random$': () => (Math.random().toString()),
								'$time$': () => ((new Date()).getTime().toString()),
								..._replacers
							};
							const replaceObj = {};
							for (let [mark, func] of Object.entries(replacers)) {
								replaceObj[mark] = func();
							}
							return replaceText(url, replaceObj);;
						}
					}

					function getSortedImagers(features=[]) {
						const stat = CM.getConfig('stat');
						const total = stat.total;
						delete stat.total;

						// filter requested features
						CONST.Imagers.forEach(imager => {
							features.some(f => !imager.features || !imager.features.includes(f)) && delete stat[imager.id];
						});

						return Object.values(stat).map(imager => ({
							id: imager.id,
							score: score(imager)
						})).sort((i1, i2) => (i2.score - i1.score)).map(imager => imager.id);

						function score(imager) {
							// Whether last three uploads successed takes 0.4 point
							// Last three upload takes 0.25, 0.1, 0.05 points in order
							const lastScore = [0.25, 0.1, 0.05].reduce((score, n, index) => {
								return score + n * (imager.last[index] ? 1 : 0);
							}, 0);

							// Whether imager stat record is enough takes 0.2 point
							const numScore = (imager.all / total.all) >= (1 / CONST.Imagers.length) ? 0.2 : 0;

							// Success rate takes 0.4 point
							const sucScore = imager.all ? (imager.success / imager.all) * 0.4 : 0;

							// Sum up all three scores
							return lastScore + numScore + sucScore;
						}
					}

					function record(imager_id, success) {
						if (typeof imager_id !== 'string') {
							Err(`Uploader.record: imager_id should be a string, not ${typeof success}`, 1);
						}
						if (!CONST.Imagers.some(i => i.id === imager_id)) {
							Err(`Uploader.record: imager_id(${imager_id}) not found`, 1);
						}
						if (typeof success !== 'boolean') {
							Err(`Uploader.record: success should be a boolean, not ${typeof success}`, 1);
						}

						[imager_id, 'total'].forEach(key => {
							CONFIG.stat[key].all++;
							CONFIG.stat[key][success ? 'success' : 'fail']++;
						});

						CONFIG.stat[imager_id].last.unshift(success);
						CONFIG.stat[imager_id].last.pop();
					}

					function isConnectErr(err) {
						return typeof err === 'object' && err !== null && typeof err.error === 'string' && err.error.startsWith('Refused to connect to ');
					}

					function init() {
						for (const imager of CONST.Imagers) {
							if (!CONFIG.stat[imager.id]) {
								CONFIG.stat[imager.id] = {
									id: imager.id, // Imager id
									last: [false, false, false],   // Whether last three upload successed
									success: 0,    // Total success count
									fail: 0,       // Total fail count
									all: 0,        // Total uploads count
								};
							}
							if (!CONFIG.stat.total) {
								CONFIG.stat.total = {
									success: 0,    // Total success count for all imagers
									fail: 0,       // Total fail count for all imagers
									all: 0,        // Total uploads count for all imagers
								};
							}
						}
					}
				}

				// New solution
				function registerElement() {
					// ImageUploader custom element
					/* ImageUploader design:
					<image-uploader>
						shadowroot (closed):
							<div id='wrapper'>
								<div id='image'>
									<span id='closebtn'></span>
									<img id='img' src>
									<br id='br'>
									<span id='placeholder' class='empty local uploaded error'>状态提示文本(无图片,未上传,上传成功,上传失败)</span>
								</div>
								<div id='cover'>拖入时提示文本,覆盖在整个element最上层显示</div>
							</div>
							<style>CSS Here</style>
					</image-uploader>

					Methods:
					[x] ImageUploader.prototype.upload:    Upload user-selected image
					[?] ImageUploader.prototype.clear:     Clear user-selected image
					[x] ImageUploader.prototype.load(url): download image blob from url and <set> to ImageUploader.prototype.image

					Properties:
					[x] ImageUploader.prototype.image: <get/set> image file/blob
					[x] ImageUploader.prototype.url: <get/set> uploaded image url
						[x] [onset]: set url property and img.src directly (act as this url is an uploaded image url)
						[x] [onupload]: set uploaded url to ImageUploader.prototype.url
					[x] ImageUploader.prototype.status: <get> upload status, 'uploaded'/'uploading'/'error'/'waiting'/'empty'
					[x] ImageUploader.prototype.autoUpload: <get/set> whether upload user selected images automatically

					Events:
					[?] error: triggers on upload error
					[?] load: triggers on upload finish

					Behaviors:
					[x] handle drag & drop
						[x] dragover: show cover
						[x] dragleave: hide cover
						[x] drop: auto set image and upload if ImageUploader.prototype.autoUpload === true

					Apperence:
					[x] CSS
					*/
					class ImageUploader extends HTMLElement {
						#elements = {};
						#image = {};
						#url = '';
						#status = 'empty';
						#autoUpload = true;
						#UPLOADER = new Uploader();

						constructor() {
							// Always call super() first
							super();

							// Const
							const CSS_ImageUploader = `#wrapper{cursor: pointer; position: relative;} #closebtn{position: absolute; right: 0; top: 0; width: 1em; height: 1em; font-size: 2em; text-align: center; border: 1px dashed rgba(0,0,0,0);} #closebtn:hover,#closebtn:focus{border: 1px dashed rgba(0,0,0,0.3);} #img{width: 100%; height: 100%;} #placeholder{text-align: center;} #cover{position: absolute; left: 0; top: 0; width: calc(100% - 20px); height: calc(100% - 20px); z-index: 1; background-color: rgba(255, 255, 255, 0.8); border: 10px grey dashed; pointer-events: none;} #covertext{position: absolute; top: calc(50% + 10px); left: 50%; margin-top: -0.5em; margin-left: -3em; font-size: 30px;}`;

							// Save this
							const _this = this;

							// Element references
							const elements = this.#elements;

							// Shadowroot
							elements.shadow = this.attachShadow({mode: 'closed'});

							// Wrapper
							elements.wrapper = $CrE('div');
							elements.wrapper.id = 'wrapper';
							elements.shadow.appendChild(elements.wrapper);
							mousetip.settip(elements.wrapper, CONST.Text.UploaderHoverText);

							// Img container
							elements.imgContainer = $CrE('div');
							elements.imgContainer.id = 'imgContainer';
							elements.wrapper.appendChild(elements.imgContainer);

							// Close button
							elements.closebtn = $CrE('span');
							elements.closebtn.id = 'closebtn';
							elements.closebtn.classList.add(CommonStyle.ClassName.Button);
							elements.closebtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>';
							elements.imgContainer.appendChild(elements.closebtn);
							$AEL(elements.closebtn, 'click', e => {
								e.stopPropagation();
								_this.clear();
							});
							mousetip.settip(elements.closebtn, CONST.Text.ClickToClear);

							// Img
							elements.img = $CrE('img');
							elements.img.id = 'img';
							elements.imgContainer.appendChild(elements.img);
							$AEL(elements.img, 'load', e => elements.imgContainer.style.removeProperty('display'));
							$AEL(elements.img, 'error', e => elements.imgContainer.style.display = 'none');

							// br
							elements.br = $CrE('br');
							elements.br.id = 'br';
							elements.imgContainer.appendChild(elements.br);

							// Placeholder
							elements.placeholder = $CrE('div');
							elements.placeholder.id = 'placeholder';
							elements.wrapper.appendChild(elements.placeholder);

							// Cover
							elements.cover = $CrE('div');
							elements.cover.id = 'cover';
							elements.cover.style.display = 'none';
							elements.wrapper.appendChild(elements.cover);

							// Cover text
							elements.covertext = $CrE('div');
							elements.covertext.id = 'covertext';
							elements.covertext.innerText = CONST.Text.CoverText;
							elements.cover.appendChild(elements.covertext);

							// Style
							elements.style = $CrE('style');
							elements.style.innerHTML = CSS_ImageUploader;
							elements.shadow.appendChild(elements.style);
							elements.shadow.appendChild(CommonStyle.ElmStyle.cloneNode(true));

							// Drag & drop
							$AEL(elements.wrapper, 'dragover', e => {
								// Preventdefault to allow drop
								e.preventDefault();

								// Display text hint on placeholder when dragover
								elements.placeholder.innerText = CONST.Text.CoverText;

								// Display cover when dragover && img shown
								if (elements.img.src && elements.img.width && elements.img.height) {
									const cover = elements.cover;
									cover.style.removeProperty('display');
								}
							});
							$AEL(elements.wrapper, 'drop', e => {
								// Preventdefault
								e.preventDefault();

								// Recover text hint on placeholder
								_this.#setStatus(_this.#status);

								// Hide cover when drop
								elements.cover.style.display = 'none';

								// Handle dropped data
								const items = e.dataTransfer.items;
								if (items.length > 1 && items[0].type !== 'text/uri-list') {
									alertify.alert(CONST.Text.ScriptTitle, CONST.Text.OneImageOnly);
									return false;
								}
								const item = items[0];
								if (item.kind !== 'file' && item.kind !== 'string') {
									// item is in DataTransferItem format, not directly a file or a string
									// Check whether item data is a file or a urilist, return if not
									return false;
								}
								if (item.type.split('/')[0] === 'image') {
									// Drag & drop image file
									const file = item.getAsFile();
									_this.image = file;
									_this.#autoUpload && _this.upload();
								} else if (item.type === 'text/uri-list') {
									// Drag & drop web image ([{type: 'text/uri-list'}, {type: 'text/html'}])
									item.getAsString(str => {
										if (!/^https?:\/\/[ \S]/.test(str) && !str.startsWith('blob:')) {
											alertify.alert(CONST.Text.ScriptTitle, CONST.Text.ImageOnly);
											return false;
										}
										_this.load(str);
									});
								}
							});
							$AEL(elements.wrapper, 'dragleave', e => {
								// Preventdefault
								e.preventDefault();

								// Recover text hint on placeholder
								_this.#setStatus(_this.#status);

								// Hide cover when drop
								elements.cover.style.display = 'none';
							});

							// Click to select
							$AEL(elements.wrapper, 'click', e => {
								const input = $$CrE({
									tagName: 'input',
									props: {
										type: 'file'
									}
								});
								$AEL(input, 'change', e => {
									const file = input.files[0];
									if (file.type.split('/')[0] !== 'image') {
										alertify.alert(CONST.Text.ScriptTitle, CONST.Text.ImageOnly);
										return false;
									}
									_this.image = file;
									_this.#autoUpload && _this.upload();
								});
								input.click();
							});

							this.#setStatus('empty');
						}

						get image() {
							return this.#image.image || null;
						}

						set image(val) {
							if (isBlobFile(val)) {
								const image = this.#image;

								// Revoke old object url
								image.objurl && URL.revokeObjectURL(image.objurl);

								// Save new image and object url
								image.image = val;
								image.objurl = URL.createObjectURL(val);

								// Set image src
								this.#elements.img.src = image.objurl;

								// Update status
								this.#setStatus('waiting');
							}
							return val;

							function isBlobFile(val) {
								return typeof val.toString === 'function' && ['[object Blob]', '[object File]'].includes(val.toString());
							}
						}

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

						set url(val) {
							if ((typeof val !== 'string' || !val.match(/(https?:\/\/|data:\/\/)/)) && val) {
								return val;
							}
							if (!val) {
								val = '';
							}
							this.#url = val;
							this.#elements.img.src.startsWith('blob:') && URL.revokeObjectURL(this.#elements.img.src);
							this.#elements.img.src = val;
							this.#setStatus('empty');

							return val;
						}

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

						set autoUpload(val) {
							this.#autoUpload = typeof val === 'boolean' ? val : this.#autoUpload;
							return val;
						}

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

						clear() {
							this.#image = {};
							this.#url = '';
							this.#setStatus('empty');
							this.#elements.img.src = '';
						}

						load(url) {
							GM_xmlhttpRequest({
								method: 'GET',
								url: url,
								responseType: 'blob',
								onload: res => {
									const blob = res.response;
									this.image = blob;
									this.#autoUpload && this.upload();
								},
								onerror: err => {
									typeof err.error === 'string' && err.error.startsWith('Refused to connect to ') &&
										alertify.alert(CONST.Text.ConnectRequired_Title, CONST.Text.ConnectRequired);
								},
							});
						}

						upload() {
							const _this = this;
							this.#setStatus('uploading');
							this.#upload(function onload(url) {
								_this.#elements.img.src.startsWith('blob:') && URL.revokeObjectURL(_this.#elements.img.src);
								_this.#elements.img.src = url;
								_this.#url = url;
								_this.#setStatus('uploaded');
								_this.dispatchEvent(new Event('load'));
							}, function onerror() {
								_this.#setStatus('error');
								_this.dispatchEvent(new Event('error'));
							})
						}

						#upload(onload, onerror) {
							const image = this.#image;
							this.#url = null;

							if (!image) {
								return false;
							}

							const file = new File([image.image], `image.${getExtname(image.image.type)}`);
							this.#UPLOADER.upload(file, onload, onerror);

							function getExtname(...args) {
								const map = {
									'image/png': 'png',
									'image/jpg': 'jpg',
									'image/gif': 'gif',
									'image/bmp': 'bmp',
									'image/jpeg': 'jpeg',
									'image/webp': 'webp',
									'image/tiff': 'tiff',
									'image/vnd.microsoft.icon': 'ico',
									'default': 'jpg'
								};
								return map[args.find(a => map[a]) || 'default'];
							}
						}

						#setStatus(status) {
							const STATUSES = ['empty', 'waiting', 'uploading', 'uploaded', 'error'];
							const TEXT = CONST.Text.StatusText;
							const list = this.#elements.placeholder.classList;
							STATUSES.forEach(s => {
								s === status && !list.contains(s) && list.add(s);
								s !== status && list.contains(s) && list.remove(s);
							});
							this.#elements.placeholder.innerText = TEXT[status];
							this.#status = status;
						}
					}

					window.customElements.define('image-uploader', ImageUploader);
				}
			}
		},

		// ChapterUnlocker
		{
			name: '在线阅读解锁',
			description: '技术测试',
			id: 'ChapterUnlocker',
			system: false,
			checker: {
				type: 'func',
				value: () => {
					return (location.pathname.startsWith('/novel/') || location.pathname.match(/\/modules\/article\/reader.php/)) && unsafeWindow.chapter_id !== '0'
				}
			},
			func: function() {
				const AndroidAPI = require('AndroidAPI');
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');
				const TEXT_GUI_NOVEL_FILLING = `</br><span class=${escJsStr(CommonStyle.ClassName.Text)}>[轻小说文库+] 正在获取章节内容...</span>`;

				detectDom('#footlink', function(footlink) {
					// Check whether needs filling
					if ($('#contentmain>span')) {
						if ($('#contentmain>span').innerText.trim() !== 'null') {
							return false;
						}
					} else {return false;}

					// prepare
					const content = $('#content');
					content.innerHTML = TEXT_GUI_NOVEL_FILLING;
					const charset = utils.getLang();

					// Get content xml
					AndroidAPI.getNovelContent({
					aid: unsafeWindow.article_id,
					cid: unsafeWindow.chapter_id,
					lang: charset,
					callback: function(text) {
						const imgModel = '<div class="divimage"><a href="{U}" target="_blank"><img src="{U}" border="0" class="imagecontent"></a></div>';

						// Trim whitespaces
						text = text.trim();

						// Get images like <!--image-->http://pic.wenku8.com/pictures/0/716/24406/11588.jpg<!--image-->
						const imgUrls = text.match(/<!--image-->[^<>]+?<!--image-->/g) || [];

						// Parse <img> for every image url
						let html = '';
						for (const url of imgUrls) {
							const index = text.indexOf(url);
							const src = utils.htmlEncode(url.match(/<!--image-->([^<>]+?)<!--image-->/)[1]);
							html += utils.htmlEncode(text.substring(0, index)).replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\n', '</br>');
							html += imgModel.replaceAll('{U}', src);
							text = text.substring(index + url.length);
						}
						html += utils.htmlEncode(text);

						// Set content
						content.innerHTML = html;
					}
				});
				});

				return true;
			}
		},

		// DownloadUnlocker
		{
			name: '下载解锁',
			description: '技术测试',
			id: 'DownloadUnlocker',
			system: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/packshow\.php\?/
				]
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');

				const CONST = {
					Text: {
						AnnounceText: '解锁功能加载完毕,仅供技术测试',
						PackshowInit: '正在初始化下载页面,请稍候...',
						PackshowInitTitle: '初始化...',
						PackshowTitle: {
							txt: '{N} 轻小说TXT下载 - 轻小说文库',
							txtfull: '{N} 轻小说TXT全本下载 - 轻小说文库',
							umd: '{N} 轻小说UMD电子书下载 - 轻小说文库',
							jar: '{N} 轻小说JAR电子书下载 - 轻小说文库',
						},
						PackshowReady: '初始化下载页面成功',
						UnkownValue: '未知'
					},
					URL: {
						BookIntro: `https://${location.host}/book/{A}.htm`
					},
					DomRes: {
						DownloadPanel: {
							title: ['《{Name}》小说TXT、UMD、JAR电子书下载', '《{Name}》小說TXT、UMD、JAR電子書下載'],
							style: 'width:820px;height:35px;margin:0px auto;padding:0px;',
							links: [{
								style: 'width:210px; float:left; text-align:center;',
								url: `https://${location.host}/modules/article/packshow.php?id={AID}&type=txt`,
								text: ['TXT简繁分卷', 'TXT簡繁分卷']
							}, {
								style: 'width:210px; float:left; text-align:center;',
								url: `https://${location.host}/modules/article/packshow.php?id={AID}&type=txtfull`,
								text: ['TXT简繁全本', 'TXT簡繁全本']
							}, {
								style: 'width:210px; float:left; text-align:center;',
								url: `https://${location.host}/modules/article/packshow.php?id={AID}&type=umd`,
								text: ['UMD分卷下载', 'UMD分卷下載']
							}, {
								style: 'width:190px; float:left; text-align:center;',
								url: `https://${location.host}/modules/article/packshow.php?id={AID}&type=jar`,
								text: ['JAR分卷下载', 'JAR分卷下載']
							}]
						}
					}
				};

				const functions = [{
					checker: {
						type: 'regurl',
						value: [
							/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm/,
							/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php/
						]
					},
					detectDom: '.main.m_foot',
					func: function() {
						const DPM = new DownloadPanelManager(CONST.DomRes.DownloadPanel);

						function DownloadPanelManager(detail) {
							const DPM = this;

							const L = utils.getLang();
							const content = $('#content');
							const aid = getAID();
							const name = $('#content table table b').innerText;
							const panel = $('#content fieldset>legend>b') || makePanel();

							function makePanel() {
								const board = $(content, 'table:nth-of-type(2) td:nth-of-type(2) .hottext:nth-of-type(2)');
								board.className = CommonStyle.ClassName.Text;
								board.innerText = CONST.Text.AnnounceText

								const container = $CrE('div');
								const fieldset = $CrE('fieldset');
								fieldset.style.cssText = detail.style;
								container.appendChild(fieldset);

								const legend = $CrE('legend');
								const b = $CrE('b');
								b.innerText = replaceText(detail.title[L], {'{AID}': aid, '{Name}': name});
								legend.appendChild(b);
								fieldset.appendChild(legend);

								for (const link of detail.links) {
									const div = $CrE('div');
									const a = $CrE('a');
									div.style.cssText = link.style;
									a.href = replaceText(link.url, {'{AID}': aid, '{Name}': name});
									a.innerText = link.text[L];
									div.appendChild(a);
									fieldset.appendChild(div);
								}

								$('#content>div:first-child').insertAdjacentElement('beforeend', container);

								return container;
							}

							function getAID() {
								const m1 = location.pathname.split('/').pop().match(/\d+/);
								return m1 ? m1[0] : getUrlArgv('id');
							}
						}
					}
				}, {
					checker: {
						type: 'regurl',
						value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/packshow\.php\?/
					},
					detectDom: '.blocknote, .main.m_foot',
					func: function(returnValue) {
						if ($All('.block').length > 1) {
							FL_postMessage('PackshowReady');
							return {packshowReady: true};
						}

						let xmlInfo, xmlIndex;
						const AndroidAPI = require('AndroidAPI');
						const AM = new AsyncManager();
						const aid = getUrlArgv('id');
						const type = getUrlArgv('type');
						const lang = utils.getLang();

						// Soft alert
						const block_loading = $('.blockcontent>:first-child');
						block_loading.innerText = `\n\n${CONST.Text.PackshowInit}\n\n\n`;
						block_loading.classList.add(require('CommonStyle').ClassName.Text);
						block_loading.style.fontSize = '2em';
						block_loading.style.textAlign = 'center';
						$('.blocktitle').innerText = CONST.Text.PackshowInitTitle;
						document.title = CONST.Text.PackshowInitTitle;

						// Load model page
						const url = location.href.replace(/([\?&]id=)\d+/, '$11');
						utils.getDocument(url, doc => {
							const div = $('.block').parentElement.parentElement;
							div.style.display = 'none';
							for (const elm of [...doc.body.children]) {
								document.body.insertBefore(elm, div);
							}
							div.remove();
							$('#plus-api-beautify')?.remove();
							AM.finish();
						});
						AM.add();

						// Load shortInfo
						AndroidAPI.getNovelShortInfo({
							aid, lang,
							callback: xml => {
								xmlInfo = xml;
								AM.finish();
							}
						});
						AM.add();

						// Load index
						AndroidAPI.getNovelIndex({
							aid, lang,
							callback: xml => {
								xmlIndex = xml;
								AM.finish();
							}
						});
						AM.add();

						AM.onfinish = fetchFinish;
						AM.finishEvent = true;

						function fetchFinish() {
							// Elements
							const content = $(document, '#content');
							const table = $(content, 'table');
							const tbody = $(table, 'tbody');

							// Data
							const name = $(xmlInfo, 'data[name="Title"]').childNodes[0].nodeValue;
							const lastupdate = $(xmlInfo, 'data[name="LastUpdate"]').getAttribute('value');
							const aBook = $(table, 'caption>a:first-child');
							const charsets = ['gbk', 'utf-8', 'big5', 'gbk', 'utf-8', 'big5'];
							const innerTexts = [
								['简体(G)', '简体(U)', '繁体(U)', '简体(G)', '简体(U)', '繁体(U)'],
								['簡體(G)', '簡體(U)', '繁體(U)', '簡體(G)', '簡體(U)', '繁體(U)']
							][lang];

							// Set Title
							document.title = replaceText(CONST.Text.PackshowTitle[type], {'{N}': name});

							// Set book
							aBook.innerText = name;
							aBook.href = replaceText(CONST.URL.BookIntro, {'{A}': aid});

							// Load book index
							loadIndex();

							// Soft alert
							alertify.success(CONST.Text.PackshowReady);
							block_loading.innerText = `\n\n${CONST.Text.PackshowReady}\n\n\n`;

							// callback
							FL_postMessage('PackshowReady');
							returnValue.packshowReady = true;

							// Book index loader
							function loadIndex() {
								switch (type) {
									case 'txt':
										loadIndex_txt();
										break;
									case 'txtfull':
										loadIndex_txtfull();
										break;
									case 'umd':
										loadIndex_umd();
										break;
									case 'jar':
										loadIndex_jar();
										break;
								}
							}

							// Book index loader for type txt
							function loadIndex_txt() {
								// Clear tbody trs
								for (const tr of $All(table, 'tr+tr')) {
									tbody.removeChild(tr);
								}

								// Make new trs
								for (const volume of $All(xmlIndex, 'volume')) {
									const tr = makeTr(volume);
									tbody.appendChild(tr);
								}

								function makeTr(volume) {
									const tr = $CrE('tr');
									const [tdName, td1, td2] = [$CrE('td'), $CrE('td'), $CrE('td')];
									const a = Array(6);
									const vid = volume.getAttribute('vid');
									const vname = volume.childNodes[0].nodeValue;

									// init tds
									tdName.classList.add('odd');
									td1.classList.add('even');
									td2.classList.add('even');
									td1.align = td2.align = 'center';

									// Set volume name
									tdName.innerText = vname;

									// Make <a> links
									for (let i = 0; i < a.length; i++) {
										a[i] = $CrE('a');
										a[i].target = '_blank';
										a[i].href = 'http://dl.wenku8.com/packtxt.php?aid=' + aid +
											'&vid=' + vid +
											(i >= 3 ? '&aname=' + $URL.encode(name) : '') +
											(i >= 3 ? '&vname=' + $URL.encode(vname) : '') +
											'&charset=' + charsets[i];
										a[i].innerText = innerTexts[i];
										(i < 3 ? td1 : td2).appendChild(a[i]);
									}

									// Insert whitespace textnode
									for (const i of [1, 2, 4, 5]) {
										(i < 3 ? td1 : td2).insertBefore(document.createTextNode('\n'), a[i]);
									}

									tr.appendChild(tdName);
									tr.appendChild(td1);
									tr.appendChild(td2);

									return tr;
								}
							}

							// Book index loader for type txtfull
							function loadIndex_txtfull() {
								const tr = $(tbody, 'tr+tr');
								const tds = Array.prototype.map.call(tr.children, (elm) => (elm));

								tds[0].innerText = lastupdate;
								tds[1].innerText = CONST.Text.UnkownValue;
								for (const a of $All(tds[2], 'a')) {
									a.href = a.href.replace(/id=\d+/, 'id='+aid).replace(/fname=[^&]+/, 'fname='+$URL.encode(name));
								}
							}

							// Book index loader for type umd
							function loadIndex_umd() {
								const tr = $(tbody, 'tr+tr');
								const tds = Array.from(tr.children);

								tds[0].innerText = tds[1].innerText = CONST.Text.UnkownValue;
								tds[2].innerText = lastupdate;
								tds[3].innerText = $(xmlIndex, 'volume:first-child').childNodes[0].nodeValue + '—' + $(xmlIndex, 'volume:last-child').childNodes[0].nodeValue;
								const as = [].concat(Array.from($All(tds[4], 'a'))).concat(Array.from($All(table, 'caption>a+a')));
								for (const a of as) {
									a.href = a.href.replace(/id=\d+/, 'id='+aid);
								}
							}

							// Book index loader for type jar
							function loadIndex_jar() {
								// Currently type jar is the same as type umd
								loadIndex_umd();
							}
						}
					}
				}];

				return utils.loadFuncs(functions);
			}
		},

		// Download Enhance
		{
			name: '下载增强',
			description: '下载界面增强,提供一键下载全部分卷、快捷切换下载服务器等小功能',
			id: 'DownloadEnhance',
			system: false,
			checker: {
				type: 'regurl',
				value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/packshow\.php\?/
			},
			func: function() {
				const DownloadUnlocker = require('DownloadUnlocker');
				const CommonStyle = require('CommonStyle');
				const mousetip = require('mousetip');
				const utils = require('utils');

				const CONST = {
					Text: {
						CheckHeader: '全选',
						DownloadAllChecked: '下载全部选中项,请点击右侧按钮:',
						NothingHere: '-- Nothing Here --',
						DIYDownloadHeader: '自定义名称格式下载',
						SetdownloadFormatTip: '点击设置下载文件的名称格式',
						SetdownloadFormatTitle: '自定义下载名称格式 - {TYPE}',
						SetdownloadFormatMessage: '下载{TYPE}时使用以下格式命名文件,以下标识符将会替换为对应的内容:</br>{SM}: 书名</br>{JM}: 卷名</br>{KZM}:文件扩展名(如"txt"/"umd"等,不含".")</br>{ID}: 文库的小说id</br>注:根据下载类型的不同,某些标识符可能不会被替换(比如下载TXT全本时,不存在分卷,所以{JM}不会被替换)',
						PleaseSelect: '请先在左边勾选需要下载的章节,</br>再点击批量下载按钮</br>最上面的复选框可以进行全选',
						DownloadStatus_Queued: '(排队中)',
						DownloadStatus_Downloading: '(下载中)',
						DownloadStatus_Finished: '(已下载)',
						DownloadStatus_Error: '(下载失败,请重试)',
						ServerChangerTip: '点击切换到此下载服务器',
						ServerChanged: '已切换到{Server}',
						DownloadType: {
							txt: 'TXT分卷',
							txtfull: 'TXT全本',
							umd: 'UMD电子书',
							jar: 'JAR电子书'
						}
					},
					Config_Ruleset: {
						'version-key': 'config-version',
						'ignores': ["LOCAL-CDN"],
						'defaultValues': {
							//'config-key': {},
							downloadFormat: {
								txt: '{SM} {JM}.{KZM}',
								txtfull: '{SM}.{KZM}',
								umd: '{SM}.{KZM}',
								jar: '{SM}.{KZM}',
								'config-version': 2
							}
						},
						'updaters': {
							/*'config-key': [
								function() {
									// This function contains updater for config['config-key'] from v0 to v1
								},
								function() {
									// This function contains updater for config['config-key'] from v1 to v2
								}
							]*/
							downloadFormat: [
								function(downloadFormat) {
									return {
										txt: downloadFormat.txt,
										txtfull: '{SM}.{KZM}'
									};
								},
								function(downloadFormat) {
									return {
										...downloadFormat,
										umd: '{SM}.{KZM}',
										jar: '{SM}.{KZM}'
									};
								}]
						}
					}
				};

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				CM.updateAllConfigs();

				detectDom('.main.m_foot', enhance);

				function enhance() {
					const doc = DownloadUnlocker.iframe ? DownloadUnlocker.iframe.contentDocument : document;
					return {
						'txt': enhance_txt,
						'txtfull': ehance_txtfull,
						'jar': enhance_jar,
						'umd': enhance_umd
					}[getUrlArgv('type')](doc);
				}

				function enhance_txt(doc) {
					const content = $(doc, '#content');
					const header = $(content, 'tr:first-child');

					// Header
					const thChecker = $CrE('th');
					thChecker.innerText = CONST.Text.CheckHeader;
					thChecker.style.textAlign = 'center';
					header.insertAdjacentElement('afterbegin', thChecker);

					const thDownload = $CrE('th');
					const text = $CrE('span');
					const nameSetting = $CrE('i');
					text.innerText = CONST.Text.DIYDownloadHeader;
					nameSetting.className = 'fa-solid fa-gear';
					nameSetting.style.marginLeft = '0.3em';
					nameSetting.style.cursor = 'pointer';
					mousetip.settip(nameSetting, CONST.Text.SetdownloadFormatTip);
					$AEL(nameSetting, 'click', setDownloadFormat.bind(null, 'txt'));
					thDownload.appendChild(text);
					thDownload.appendChild(nameSetting);
					header.appendChild(thDownload);

					const widths = ['5%', '35%', '20%', '20%', '20%'];
					[...$All(content, 'tr:first-child>th')].forEach((th, i) => {
						th.style.width = widths[i];
					});

					// Row
					for (const tr of $All(content, 'tbody>tr:not(:first-child)')) {
						// Batch checker
						const cTd = $CrE('td');
						const checker = $CrE('input');
						checker.type = 'checkbox';
						cTd.style.textAlign = 'center';
						cTd.style.cursor = checker.style.cursor = 'pointer';
						$AEL(cTd, 'click', e => e.srcElement === cTd && checker.click());
						cTd.appendChild(checker);
						tr.insertAdjacentElement('afterbegin', cTd);

						// Download display
						const tdName = tr.children[1];
						const vtitle = $CrE('span');
						vtitle.innerText = tdName.innerText;
						tdName.innerHTML = '';
						tdName.appendChild(vtitle);
						const dltext = $CrE('span');
						dltext.style.marginLeft = '0.3em';
						tdName.appendChild(dltext);

						// Download button
						const td = tr.children[2].cloneNode(true);
						[...td.children].forEach(a => {
							a.classList.add(CommonStyle.ClassName.Button);
							$AEL(a, 'click', e => {
								e.preventDefault();
								dltext.innerText = CONST.Text.DownloadStatus_Queued;

								downloadAndUnserielize({
									url: a.href,
									name: replaceText(CONFIG.downloadFormat.txt, {
									'{SM}': $(content, 'caption>a').innerText,
									'{JM}': tdName.children[0].innerText,
									'{KZM}': 'txt',
									'{ID}': getUrlArgv('id')
								}),
									encoding: getUrlArgv(a.href, 'charset'),
									options: {
										onloadstart: function() {
											dltext.innerText = CONST.Text.DownloadStatus_Downloading;
										},
										onload: function() {
											dltext.innerText = CONST.Text.DownloadStatus_Finished;
										},
										onerror: function() {
											dltext.innerText = CONST.Text.DownloadStatus_Error;
										}
									}
								});
							});
						});
						tr.appendChild(td);
					}

					// Batch download
					const getAllCheckbox = () => [...$All(content, 'tbody>tr:not([plus_batch])>td>input[type="checkbox"]')];
					const batchTr = $CrE('tr');
					batchTr.setAttribute('plus_batch', '');
					for (let i = 0; i < 5; i++) {
						const td = $CrE('td');
						td.style.textAlign = 'center';
						switch (i) {
							case 0: {
								const allcheck = $CrE('input');
								allcheck.type = 'checkbox';
								td.style.cursor = allcheck.style.cursor = 'pointer';
								$AEL(allcheck, 'change', e => {
									getAllCheckbox().forEach(box => {box.checked = allcheck.checked});
								});
								$AEL(td, 'click', e => e.srcElement === td && allcheck.click());
								td.appendChild(allcheck);

								const tbody = $(content, 'tbody');
								$AEL(tbody, 'change', e => {
									allcheck.checked = getAllCheckbox().every(input => input.checked);
								});
								break;
							}
							case 1: {
								td.innerText = CONST.Text.DownloadAllChecked;
								td.classList.add('odd');
								td.style.removeProperty('text-align');
								td.classList.add(CommonStyle.ClassName.Text);
								break;
							}
							case 2:
							case 3: {
								td.classList.add('even');
								td.style.color = 'grey';
								td.innerText = CONST.Text.NothingHere;
								break;
							}
							case 4: {
								td.classList.add('even');
								[...$(content, 'tbody>tr:last-child>td:last-child').children].forEach((_a, i) => {
									const a = _a.cloneNode(true);
									a.href = 'javascript:void(0);';
									a.target = 'self';
									$AEL(a, 'click', e => {
										e.preventDefault();
										const trs = [...$All(content, 'tbody>tr:not(:first-child):not([plus_batch])')].filter(tr => $(tr, 'input[type="checkbox"]').checked);
										if (trs.length) {
											trs.forEach(tr => $(tr, `td:last-child>a:nth-of-type(${i+1})`).click());
										} else {
											alertify.message(CONST.Text.PleaseSelect);
										}
										/*
										[...$All(content, 'tbody>tr:not(:first-child):not([plus_batch])')].forEach(tr => {
											if ($(tr, 'input[type="checkbox"]').checked) {
												$(tr, `td:last-child>a:nth-of-type(${i+1})`).click();
											}
										});
										*/
									});
									td.appendChild(document.createTextNode(' '));
									td.appendChild(a);
								});
								break;
							}
						}
						batchTr.appendChild(td);
					}
					$(content, 'tbody>tr:first-child').insertAdjacentElement('afterend', batchTr);

					// Sever changer
					serverChanger();
				}

				function ehance_txtfull(doc) {
					const content = $(doc, '#content');

					const td = $(content, 'tbody td:last-child');
					td.appendChild($CrE('br'));
					td.appendChild(document.createTextNode(CONST.Text.DIYDownloadHeader));
					const diyBtn = $CrE('i');
					diyBtn.className = 'fa-solid fa-gear';
					mousetip.settip(diyBtn, CONST.Text.SetdownloadFormatTip);
					diyBtn.style.marginLeft = '0.3em';
					diyBtn.style.cursor = 'pointer';
					$AEL(diyBtn, 'click', setDownloadFormat.bind(null, 'txtfull'));
					td.appendChild(diyBtn);
					td.appendChild(document.createTextNode('('));
					const text = [['简体(G)', '简体(U)', '繁体(U)'], ['簡體(G)', '簡體(U)', '繁體(U)']][utils.getLang()];
					[1, 3, 5].forEach((n, i) => {
						const _a = $(td, `a:nth-child(${n})`);
						const a = $CrE('a');
						a.href = 'javascript: void(0);';
						a.innerText = text[i];
						a.classList.add(CommonStyle.ClassName.Button);
						$AEL(a, 'click', e => {
							e.preventDefault();
							downloadAndUnserielize({
								url: _a.href,
								name: replaceText(CONFIG.downloadFormat.txtfull, {
									'{SM}': $(content, 'caption>a').innerText,
									'{KZM}': 'txt',
									'{ID}': getUrlArgv('id')
								}),
								encoding: getUrlArgv(_a.href, 'type').replace(/^txt$/, 'gbk'),
							});
						});
						td.appendChild(a);
						n < 5 && td.appendChild(document.createTextNode(' '));
					});
					td.appendChild(document.createTextNode(')'));

					// Sever changer
					serverChanger();
				}

				function enhance_jar(doc) {
					const content = $(doc, '#content');

					// DIY name download
					const th = $CrE('th');
					th.innerText = CONST.Text.DIYDownloadHeader;
					$(doc, 'tbody>:first-child').appendChild(th);
					const setting = $CrE('i');
					setting.className = 'fa-solid fa-gear';
					mousetip.settip(setting, CONST.Text.SetdownloadFormatTip);
					setting.style.marginLeft = '0.3em';
					setting.style.cursor = 'pointer';
					$AEL(setting, 'click', setDownloadFormat.bind(null, 'jar'));
					th.appendChild(setting);
					const td = $CrE('td');
					td.align = 'center';
					$(doc, 'tbody>:last-child').appendChild(td);
					const _td = td.previousElementSibling;

					const widths = [5, 5, 9, 41, 20, 20].map(num => `${num}%`);
					[...$All(doc, 'table th')].forEach((th, i) => th.width = widths[i]);

					[...$All(_td, 'a')].forEach((a, i, arr) => {
						const dlBtn = $CrE('span');
						dlBtn.classList.add(CommonStyle.ClassName.Button);
						dlBtn.innerText = a.innerText;
						$AEL(dlBtn, 'click', e => dl_GM({
							url: $(_td, `a:nth-child(${i+1})`).href,
							name: replaceText(CONFIG.downloadFormat.jar, {
								'{SM}': $(content, 'caption>a').innerText,
								'{KZM}': ['jar', 'jad'][i],
								'{ID}': getUrlArgv('id')
							})
						}));
						td.appendChild(dlBtn);

						i < arr.length && td.appendChild(doc.createTextNode(' '));
					});

					// Sever changer
					serverChanger();
				}

				function enhance_umd(doc) {
					const content = $(doc, '#content');

					// DIY name download
					const th = $CrE('th');
					th.innerText = CONST.Text.DIYDownloadHeader;
					$(doc, 'tbody>:first-child').appendChild(th);
					const setting = $CrE('i');
					setting.className = 'fa-solid fa-gear';
					mousetip.settip(setting, CONST.Text.SetdownloadFormatTip);
					setting.style.marginLeft = '0.3em';
					setting.style.cursor = 'pointer';
					$AEL(setting, 'click', setDownloadFormat.bind(null, 'umd'));
					th.appendChild(setting);
					const td = $CrE('td');
					td.align = 'center';
					$(doc, 'tbody>:last-child').appendChild(td);
					const _td = td.previousElementSibling;

					const widths = [5, 5, 9, 41, 20, 20].map(num => `${num}%`);
					[...$All(doc, 'table th')].forEach((th, i) => th.width = widths[i]);

					[...$All(_td, 'a')].forEach((a, i, arr) => {
						const dlBtn = $CrE('span');
						dlBtn.classList.add(CommonStyle.ClassName.Button);
						dlBtn.innerText = a.innerText;
						$AEL(dlBtn, 'click', e => dl_GM({
							url: $(_td, `a:nth-child(${i+1})`).href,
							name: replaceText(CONFIG.downloadFormat.jar, {
								'{SM}': $(content, 'caption>a').innerText,
								'{KZM}': ['umd'][i],
								'{ID}': getUrlArgv('id')
							})
						}));
						td.appendChild(dlBtn);

						i < arr.length && td.appendChild(doc.createTextNode(' '));
					});

					// Sever changer
					serverChanger();
				}

				function setDownloadFormat(type) {
					alertify.prompt(replaceText(CONST.Text.SetdownloadFormatTitle, {'{TYPE}': CONST.Text.DownloadType[type]}), replaceText(CONST.Text.SetdownloadFormatMessage, {'{TYPE}': CONST.Text.DownloadType[type]}), CONFIG.downloadFormat[type], (e, val) => CM.setConfig(`downloadFormat/${type}`, val), function() {});
				}

				function serverChanger() {
					// Sever changer
					const doc = DownloadUnlocker.iframe ? DownloadUnlocker.iframe.contentDocument : document;
					const content = $(doc, '#content');
					for (const b of $All(doc, '#content>b')) {
						const host = b.innerText;
						const matcher = /dl(\d+)?\.wenku8\.com/;
						if (host.match(matcher)) {
							b.classList.add(CommonStyle.ClassName.Button);
							$AEL(b, 'click', e => {
								[...$All(content, 'a[href]')].filter(a => a.href.match(matcher)).forEach(a => a.href = a.href.replace(matcher, host));
								mousetip.showtip(replaceText(CONST.Text.ServerChanged, {'{Server}': host}));
							});
							mousetip.settip(b, CONST.Text.ServerChangerTip);
						}
					}
				}

				// Args: url, name, encoding, options || <object>details
				// Encoding must be one of follows: 'utf-8'(and alias like 'utf8', 'UTF-8', 'UTF8' ...), 'gbk', 'big5'
				function downloadAndUnserielize() {
					let [url, name, encoding, options] = parseArgs([...arguments], [
						function(args, defaultValues) {
							const arg = args[0];
							return ['url', 'name', 'encoding', 'options'].map((prop, i) => arg.hasOwnProperty(prop) ? arg[prop] : defaultValues[i]);
						},
						[1, 2],
						[1, 2, 3],
						[1, 2, 3, 4]
					], ['', 'text.txt', 'utf-8', {}]);
					encoding = encoding.toLowerCase();
					const xhrOptions = {
						method: 'GET', url,
						responseType: 'blob',
						onload: function(response) {
							const reader = new FileReader();
							reader.onload = function() {
								let text = reader.result;
								for (const match of text.match(/&#\d{1,6};/g)) {
									text = text.replace(match, utils.htmlDecode(match));
								}
								const buf = encoding.replace('-', '') === 'utf8' ? new TextEncoder().encode(text) : $URL[encoding].encodeBuffer(text);
								const blob = new Blob([buf], { type: 'text/plain' });
								const objurl = URL.createObjectURL(blob);
								dl_browser(objurl, name);
								setTimeout(e => URL.revokeObjectURL(objurl));
							}
							reader.readAsText(response.response, encoding);
						},
					};
					for (const [key, value] of Object.entries(options)) {
						if (typeof value === 'function' && typeof xhrOptions[key] === 'function') {
							// Call both originalCallback and userCallback if userCallback provided
							const [originalCallback, userCallback] = [xhrOptions[key], value];
							xhrOptions[key] = function() {
								originalCallback.apply(this, arguments);
								return userCallback.apply(this, arguments);
							}
						} else {
							// Overwrite other properties
							xhrOptions[key] = value;
						}
					}
					GM_xmlhttpRequest(xhrOptions);
				}
			}
		},

		// Image batch download
		// 现在是全部插图下载,预计要做成一个小说多格式自定义下载器
		{
			name: '插图批量下载',
			description: '批量下载插图',
			id: 'ImageBatchDownloader',
			system: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm([\?#][\s\S]*)?$/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php([\?#][\s\S]*)?$/,
				]
			},
			CONST: {
				Text: {
					DownloadAllImages: '全部插图下载',
					Image: ['插图', '插圖']
				}
			},
			func: function() {
				const CONST = FuncInfo.CONST;
				const AndroidAPI = require('AndroidAPI');
				const utils = require('utils');

				const aid = getUrlArgv('id') || location.pathname.match(/\/book\/(\d+)\.htm/)[1];
				const lang = utils.getLang();

				const container = $$CrE({
					tagName: 'div',
					styles: {
						float: 'left',
						'text-align': 'center'
					}
				});
				const btn = $$CrE({
					tagName: 'a',
					props: {
						innerText: CONST.Text.DownloadAllImages,
						href: 'javascript: void(0);'
					},
					listeners: [['click', e => dlAllImgs()]]
				});
				container.appendChild(btn);

				detectDom('#content>div:nth-child(3)', e => {
					const fieldset = $('legend+div[style^="width"]').parentElement;
					fieldset.appendChild(container);
					Array.from($All(fieldset, 'div')).forEach(div => div.style.width = '164px');
				});

				function dlAllImgs() {
					DoLog('Download All Images');
					AndroidAPI.getNovelIndex({
						aid, lang,
						callback: xml => {
							[AndroidAPI, aid, lang];

							/*
							const box = alertify.
							for (const volumn of $All(xml, 'volumn')) {
								for (const chapter of $All(volumn, 'chapter')) {
									//
								}
							}
							*/

							// Simply downloads all images
							let dl = 0, all = 0;
							const update = () => btn.innerText = `${CONST.Text.DownloadAllImages}(${dl}/${all})`;
							for (const chapter of $All(xml, 'chapter')) {
								const cid = chapter.getAttribute('cid');
								AndroidAPI.getNovelContent({
									aid, cid, lang,
									callback: text => {
										const imgurls = Array.from(text.matchAll(/<!--image-->([^<>]+?)<!--image-->/g)).map(match => match[1]);
										imgurls.forEach((url, i) => {
											const bookName = $('table table span>b').innerText;
											const volumnName = chapter.parentNode.firstChild.nodeValue;
											const num = (i+1).toString().padStart(imgurls.length.toString().length, '0');
											const ext = url.match(/\.([^\/]+)$/) ? url.match(/\.([^\/]+)$/)[1] : 'jpg';
											const name = `${bookName}_${volumnName} ${CONST.Text.Image[lang]}_${num}.${ext}`;
											dl_GM({
												url, name,
												onload: e => update(++dl) // this argument has no effect, just need to increase dl lol
											});
										});
										all += imgurls.length;
									}
								});
							}
							update();
						}
					});
				}
			}
		},

		// Beautifier
		{
			name: '页面美化',
			description: '自定义页面背景图',
			id: 'beautifier',
			system: false,
			Config_Ruleset: {
				'version-key': 'config-version',
				'ignores': ["LOCAL-CDN"],
				'defaultValues': {
					//'config-key': {},
					common: {
						enable: false,
						image: null,
						cover: {
							light: {
								opacity: 70,
								color: 'white',
								blur: 0,
							},
							dark: {
								opacity: 5,
								color: 'white',
								blur: 5,
							}
						}
					},
					novel: {
						enable: false,
						image: null,
						center: false,
						'config-version': 1
					},
					review: {
						enable: false,
						image: null,
						textScale: 100
					},
					image: null,
				},
				'updaters': {
					/*'config-key': [
						function() {
							// This function contains updater for config['config-key'] from v0 to v1
						},
						function() {
							// This function contains updater for config['config-key'] from v1 to v2
						}
					]*/
					novel: [
						function(novel) {
							// add prop 'center' to novel
							return {...novel, center: false};
						}
					]
				}
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');

				const CONST = {
					Text: {
						CommonBeautify: '通用页面美化',
						NovelBeautify: '阅读页面美化',
						ReviewBeautify: '书评页面美化',
						DefaultBeautify: '默认页面美化图片',
						Enable: '启用',
						BackgroundImage: '背景图片',
						AlertTitle: '页面美化设置',
						InvalidImageUrl: '图片链接格式错误</br>仅仅接受http/https/data链接',
						textScale: '文字大小缩放'
					},
					ClassName: {
						BgImage: 'plus-cbty-image',
						BgCover: 'plus-cbty-cover',
						CSSCommon: 'plus-beautifier-common',
						CSSNovel: 'plus-beautifier-novel',
						CSSReview: 'plus-beautifier-review',
					},
					CSS: {
						Common: ':not(.plus-cbty)>:is(.plus-cbty-image, .plus-cbty-cover) {display: none;}.plus-cbty .plus-cbty-image {position: fixed;top: 0;left: 0;z-index: -2;width: 100vw;height: 100vh;}.plus-cbty .plus-cbty-cover {position: fixed;top: 0;left: calc((100vw - 960px) / 2);z-Index: -1;width: 960px;height: 100vh;backdrop-filter: blur([CoverBlur-Light]px);}.plus-cbty.plus-darkmode .plus-cbty-cover {backdrop-filter: blur([CoverBlur-Dark]px);}.plus-cbty .plus-cbty-cover::before {content: "";position: absolute;top: 0;right: 0;left: 0;bottom: 0;z-index:-1;background-color: [CoverColor-Light];opacity: [CoverOpacity-Light];}.plus-cbty.plus-darkmode .plus-cbty-cover::before {background-color: [CoverColor-Dark];opacity: [CoverOpacity-Dark];}body.plus-cbty {overflow: auto;margin: 0;}body.plus-cbty>:is(.main, table.css) {position: relative;margin-left: calc((100vw - 960px) / 2);}.plus-cbty :is(.textarea, .text) {background-color: rgba(255,255,255,0.9);}.plus-cbty #headlink{background-color: rgba(255,255,255,0.1);}.plus-cbty:is(.plus-darkmode, :not(.plus-darkmode)) :is(table.grid td, .odd, .even, .blockcontent) {background-color: transparent !important;}.plus-cbty:is(.plus-darkmode, :not(.plus-darkmode)) :is(.pagelink, .pagelink strong, .pagelink a:hover) {background-color: transparent;}.DoNotUse.plus-cbty:is(.plus-darkmode, :not(.plus-darkmode)) :is(.main.m_top, .nav, .navinner, .navlist, .nav li, .nav a.current, .nav a:hover, .nav a:active):not(#NotAElement>#NotAElement) {background: transparent;}.plus-cbty #mask {width: 100vw !important;height: 100vh !important;}.plus-cbty :is(.blocktitle, .nav, a) {user-select: none;}',
						Novel: 'body.plus-cbty {width: 100vw;height: 100vh;overflow: overlay;margin: 0px;background-color: rgba(255,255,255,0.7);}.plus-cbty #contentmain {overflow-y: auto;height: calc(100vh - [H]);min-width: 0px;max-width: [W];}.plus-cbty :is(#adv1, #adtop, #headlink, #footlink, #adbottom) {overflow: overlay;min-width: 0px;max-width: 100vw;}.plus-cbty :is(#adv900, #adv5) {max-width: 100vw;}.plus-cbty #contentmain {scrollbar-color: rgba(0,0,0,0.4) rgba(255,255,255,0.3);}.plus-cbty img.imagecontent{width:100% }.plus-cbty #contentmain::-webkit-scrollbar-thumb {background-color: rgba(0,0,0,0.4);}.plus-cbty #contentmain::-webkit-scrollbar {background-color: rgba(255,255,255,0.3);}',
						Review: '.plus-cbty #content > table > tbody > tr > td {background-color: transparent;overflow: auto;}.plus-cbty:is(.plus-darkmode, :not(.plus-darkmode)) :is(table.grid th, form[name="frmreview"] caption, input[type="checkbox"]::after) {background-color: transparent;}.plus-cbty #content {height: 100vh;overflow: auto;}.plus-cbty :is(.m_top, .m_head, .main.nav, .m_foot) {display: none;}.plus-cbty .main {margin-top: 0px;}.plus-cbty #content>table>tbody>tr{font-size: [S]%;line-height: 100%;}.plus-cbty :is(.jieqiQuote, .jieqiCode, .jieqiNote) {font-size: inherit;}.plus-cbty #content>table>tbody>tr>td:nth-of-type(2) {overflow-wrap: anywhere;}.plus-cbty #content>table>tbody>tr>td:nth-of-type(2) a {user-select: auto;}.plus-cbty .jieqiQuote *{overflow-wrap:break-word;max-width:calc(937px / 100 * 80);}.plus-cbty form[name="frmreview"]{position:relative;}'
					}
				};

				// Config
				const CM = new ConfigManager(FuncInfo.Config_Ruleset);
				const CONFIG = CM.Config;
				CM.updateAllConfigs();

				// Beautifier pages
				detectDom('body', function() {
					const API = getAPI();
					if (/\.(jpe?g|png|webp|gif|bmp|txt|js|css)/.test(location.pathname)) {
						// Stop running in image, code, or pure text papes
						return false;
					} else if ((API[0] === 'novel' || location.pathname.match(/\/modules\/article\/reader.php/)) && unsafeWindow.chapter_id !== '0') {
						CONFIG.novel.enable && (document.readyState !== 'loading' ? novel() : $AEL(document, 'DOMContentLoaded', e => novel()));
					} else if (API.join('/') === 'modules/article/reviewshow.php') {
						//CONFIG.review.enable && (document.readyState !== 'loading' ? review() : $AEL(window, 'load', e => review()));
						CONFIG.review.enable && detectDom('.main.m_foot', e => review());
					} else {
						CONFIG.common.enable && common();
					}
				});

				return {};

				// Beautifier for all wenku pages
				function common(from='common') {
					const src = (CONFIG[from] && CONFIG[from].image) || CONFIG.image;

					if (src) {
						const img = $CrE('img');
						img.src = src;
						img.classList.add(CONST.ClassName.BgImage);
						document.body.appendChild(img);
					}

					const cover = $CrE('div');
					cover.classList.add(CONST.ClassName.BgCover);
					document.body.appendChild(cover);

					document.body.classList.add('plus-cbty');
					addStyle(replaceText(CONST.CSS.Common, {
						'[CoverBlur-Light]': CONFIG.common.cover.light.blur.toString(),
						'[CoverOpacity-Light]': (CONFIG.common.cover.light.opacity / 100).toString(),
						'[CoverColor-Light]': CONFIG.common.cover.light.color.toString(),
						'[CoverBlur-Dark]': CONFIG.common.cover.dark.blur.toString(),
						'[CoverOpacity-Dark]': (CONFIG.common.cover.dark.opacity / 100).toString(),
						'[CoverColor-Dark]': CONFIG.common.cover.dark.color.toString(),
					}), CONST.ClassName.CSSCommon);
					return true;
				}

				// Novel reading page
				function novel() {
					common('novel');

					const src = CONFIG.novel.image || CONFIG.image;
					const usedHeight = getRestHeight();
					const contentWidth = CONFIG.novel.center ? '958px' : '100vw';

					addStyle(CONST.CSS.Novel
						 .replaceAll('[BGI]', src)
						 .replaceAll('[H]', usedHeight)
						 .replaceAll('[W]', contentWidth),
						 CONST.ClassName.CSSNovel
					);

					document.ondblclick = beautiful_beginScroll;
					document.onmousedown = beautiful_stopScroll;
					unsafeWindow.scrolling = beautiful_scrolling;

					// Get rest height without #contentmain
					function getRestHeight() {
						let usedHeight = 0;
						['adv1', 'adtop', 'headlink', 'footlink', 'adbottom'].forEach((id) => {
							const node = $('#'+id);
							if (node instanceof Element && node.id !== 'contentmain') {
								const cs = getComputedStyle(node);
								['height', 'marginTop', 'marginBottom', 'paddingTop', 'paddingBottom', 'borderTop', 'borderBottom'].forEach((style) => {
									const reg = cs[style].match(/([\.\d]+)px/);
									reg && (usedHeight += parseInt(reg[1], 10));
								});
							};
						});
						usedHeight = usedHeight.toString() + 'px';
						return usedHeight;
					}

					// Prevent dblclick-selecting text while dblclick start scrolling
					function beautiful_beginScroll(e) {
						// filter alertify and sidepanel
						let elm = e.target;
						const FORBID = {
							classes: ['alertify'],
							ids: ['sidepanel-panel']
						};
						while (elm = elm.parentElement) {
							if ([...elm.classList].some(cls => FORBID.classes.includes(cls)) || FORBID.ids.includes(elm.id)) {
								return;
							}
						}

						unsafeWindow.timer = setInterval(beautiful_scrolling, 300 / unsafeWindow.speed);
						$('#contentmain').style.userSelect = 'none';
					}
					function beautiful_stopScroll() {
						clearInterval(unsafeWindow.timer);
						$('#contentmain').style.userSelect = '';
					}

					// Mouse dblclick scroll with beautifier applied
					function beautiful_scrolling() {
						var contentmain = $('#contentmain');
						var currentpos = contentmain.scrollTop || 0;
						contentmain.scrollTo(0, ++currentpos);
						var nowpos = contentmain.scrollTop || 0;
						if(currentpos != nowpos) unsafeWindow.clearInterval(unsafeWindow.timer);
					}
				}

				// Review reading page
				function review() {
					common('review');

					const src = CONFIG.review.image || CONFIG.image;
					const textScale = CONFIG.review.textScale;
					const main = $('#content');
					const recScrollY = e => {
						//scrollY = unsafeWindow.scrollY - ['.main.m_top', '.main.m_head', '.main.nav'].reduce((height, selector) => height + $(selector).scrollHeight, 0);
						scrollY = $('html').scrollTop - ['.main.m_top', '.main.m_head', '.main.nav'].reduce((height, selector) => height + $(selector).scrollHeight, 0);
					};
					let scrollY = 0;
					recScrollY();
					$AEL(window, 'scroll', recScrollY);
					addStyle(CONST.CSS.Review
							 .replaceAll('[BGI]', src)
							 .replaceAll('[S]', textScale.toString())
							 , CONST.ClassName.CSSReview);
					main.scrollTo(0, scrollY); // Cannot record scrollY correctly (always finds window.scrollY = 0 when recording), so stop this feature for a while
					window.removeEventListener('scroll', recScrollY);
					//hookPosition();
					// 2023-06-21 With new CSS specified form[name="frmreview"]{position:relative;}, there's no need to hook UBBEditor.GetPosotion anymore

					// Deletable
					function hookPosition() {
						if (typeof unsafeWindow.UBBEditor !== 'object') {
							hookPosition.wait = hookPosition.wait ? hookPosition.wait : 0;
							if (++hookPosition.wait > 50) {return false;}
							hookPosition.wait % 10 === 0 && DoLog('hookPosition: UBBEditor not loaded, waiting...');
							setTimeout(hookPosition, 500);
							return false;
						}
						unsafeWindow.UBBEditor.GetPosition = function (obj) {
							var r = new Array();
							r.x = obj.offsetLeft;
							r.y = obj.offsetTop;
							while (obj = obj.offsetParent) {
								if (unsafeWindow.$(obj).getStyle('position') == 'absolute' || unsafeWindow.$(obj).getStyle('position') == 'relative') break;
								r.x += obj.offsetLeft;
								r.y += obj.offsetTop;
							}
							r.x -= main.scrollLeft;
							r.y -= main.scrollTop;
							return r;
						}
					}
				}

				function getAPI() {
					return location.pathname.replace(/^\//, '').split('/');
				}
			},
			setting: function setter() {
				const SettingPanel = require('SettingPanel');
				const CONST = {
					Text: {
						CommonBeautify: '通用页面美化',
						NovelBeautify: '阅读页面美化',
						ReviewBeautify: '书评页面美化',
						DefaultBeautify: '默认页面美化图片',
						Enable: '启用',
						BackgroundImage: '背景图片',
						AlertTitle: '页面美化设置',
						InvalidImageUrl: '图片链接格式错误</br>仅仅接受http/https/data链接',
						CenterText: '保持正文文字居中',
						textScale: '文字大小缩放',
						CoverColorLight: '遮罩层颜色',
						CoverOpacityLight: '遮罩层不透明度',
						CoverBlurLight: '遮罩层背景模糊',
						CoverColorDark: '遮罩层颜色(深色模式)',
						CoverOpacityDark: '遮罩层不透明度(深色模式)',
						CoverBlurDark: '遮罩层背景模糊(深色模式)',
					}
				}

				const CM = new ConfigManager(FuncInfo.Config_Ruleset);
				CM.updateAllConfigs();

				const SetPanel = new SettingPanel.easySettings({
					title: CONST.Text.AlertTitle,
					areas: [{
						title: CONST.Text.CommonBeautify,
						items: [{
							text: CONST.Text.Enable,
							path: 'common/enable',
							type: 'boolean'
						}, {
							text: CONST.Text.BackgroundImage,
							path: 'common/image',
							type: ['image', 'string'],
							checker: imageUrlChecker,
						}, {
							text: CONST.Text.CoverColorLight,
							path: 'common/cover/light/color',
							type: ['string']
						}, {
							text: CONST.Text.CoverOpacityLight,
							path: 'common/cover/light/opacity',
							type: ['number'],
							children: $$CrE({
								tagName: 'span',
								props: { innerText: '%' }
							})
						}, {
							text: CONST.Text.CoverBlurLight,
							path: 'common/cover/light/blur',
							type: ['number']
						}, {
							text: CONST.Text.CoverColorDark,
							path: 'common/cover/dark/color',
							type: ['string']
						}, {
							text: CONST.Text.CoverOpacityDark,
							path: 'common/cover/dark/opacity',
							type: ['number'],
							children: $$CrE({
								tagName: 'span',
								props: { innerText: '%' }
							})
						}, {
							text: CONST.Text.CoverBlurDark,
							path: 'common/cover/dark/blur',
							type: ['number']
						}]
					}, {
						title: CONST.Text.NovelBeautify,
						items: [{
							text: CONST.Text.Enable,
							path: 'novel/enable',
							type: 'boolean'
						}, {
							text: CONST.Text.BackgroundImage,
							path: 'novel/image',
							type: ['image', 'string'],
							checker: imageUrlChecker,
						}, {
							text: CONST.Text.CenterText,
							path: 'novel/center',
							type: 'boolean'
						}]
					}, {
						title: CONST.Text.ReviewBeautify,
						items: [{
							text: CONST.Text.Enable,
							path: 'review/enable',
							type: 'boolean'
						}, {
							text: CONST.Text.BackgroundImage,
							path: 'review/image',
							type: ['image', 'string'],
							checker: imageUrlChecker,
						}, {
							text: CONST.Text.textScale,
							path: 'review/textScale',
							type: 'number',
							children: $$CrE({
								tagName: 'span',
								props: { innerText: '%' }
							})
						}]
					}, {
						title: CONST.Text.DefaultBeautify,
						items: [{
							text: CONST.Text.BackgroundImage,
							path: 'image',
							type: ['image', 'string'],
							checker: imageUrlChecker,
						}]
					}]
				}, CM);

				/*
				const Panel = SettingPanel.SettingPanel;
				const _SetPanel = new Panel({
					buttons: 'saver',
					header: CONST.Text.AlertTitle,
					tables: [{
						rows: [{
							blocks: [{
								isHeader: true,
								colSpan: 2,
								innerText: CONST.Text.CommonBeautify
							}]
						},{
							blocks: [{
								innerText: CONST.Text.Enable
							},{
								options: [{
									path: 'common/enable',
									type: 'boolean'
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.BackgroundImage
							},{
								options: [{
									path: 'common/image',
									type: ['image', 'string'],
									checker: imageUrlChecker,
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CoverColorLight
							},{
								options: [{
									path: 'common/cover/light/color',
									type: ['string']
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CoverOpacityLight
							},{
								options: [{
									path: 'common/cover/light/opacity',
									type: ['number']
								}],
								children: [(() => {
									const span = $CrE('span');
									span.innerText = '%';
									return span;
								}) ()]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CoverBlurLight
							},{
								options: [{
									path: 'common/cover/light/blur',
									type: ['number']
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CoverColorDark
							},{
								options: [{
									path: 'common/cover/dark/color',
									type: ['string']
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CoverOpacityDark
							},{
								options: [{
									path: 'common/cover/dark/opacity',
									type: ['number']
								}],
								children: [(() => {
									const span = $CrE('span');
									span.innerText = '%';
									return span;
								}) ()]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CoverBlurDark
							},{
								options: [{
									path: 'common/cover/dark/blur',
									type: ['number']
								}]
							}]
						}]
					},{
						rows: [{
							blocks: [{
								isHeader: true,
								colSpan: 2,
								innerText: CONST.Text.NovelBeautify
							}]
						},{
							blocks: [{
								innerText: CONST.Text.Enable
							},{
								options: [{
									path: 'novel/enable',
									type: 'boolean'
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.BackgroundImage
							},{
								options: [{
									path: 'novel/image',
									type: ['image', 'string'],
									checker: imageUrlChecker,
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.CenterText
							},{
								options: [{
									path: 'novel/center',
									type: 'boolean'
								}]
							}]
						}]
					},{
						rows: [{
							blocks: [{
								isHeader: true,
								colSpan: 2,
								innerText: CONST.Text.ReviewBeautify
							}]
						},{
							blocks: [{
								innerText: CONST.Text.Enable
							},{
								options: [{
									path: 'review/enable',
									type: 'boolean'
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.BackgroundImage
							},{
								options: [{
									path: 'review/image',
									type: ['image', 'string'],
									checker: imageUrlChecker,
								}]
							}]
						},{
							blocks: [{
								innerText: CONST.Text.textScale
							},{
								options: [{
									path: 'review/textScale',
									type: 'number'
								}],
								children: [(() => {
									const span = $CrE('span');
									span.innerText = '%';
									return span;
								}) ()]
							}]
						}]
					},{
						rows: [{
							blocks: [{
								isHeader: true,
								colSpan: 2,
								innerText: CONST.Text.DefaultBeautify
							}]
						},{
							blocks: [{
								innerText: CONST.Text.BackgroundImage
							},{
								options: [{
									path: 'image',
									type: ['image', 'string'],
									checker: imageUrlChecker,
								}]
							}]
						}]
					}]
				}, CM);
				*/

				function imageUrlChecker(e, value) {
					if (value && !value.match(/.+:/)) {
						alertify.alert(CONST.Text.AlertTitle, CONST.Text.InvalidImageUrl);
						return false;
					}
					e.target.value = value || null;
					return true;
				}

				function opacityChecker(e, value) {
					return value >= 0 && value <= 100
				}
			},
		},

		// AdBlock
		{
			name: '广告拦截',
			description: '去除书籍目录页面和章节阅读页面的横幅广告',
			id: 'adblock',
			system: false,
			checker: {
				type: 'regurl',
				value: [
					// Reading page
					/^https?:\/\/www\.wenku8\.(net|cc)\/novel\//,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reader\.php/,

					// .h_banner
					/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/index\.php/,
				]
			},
			func: function() {
				const utils = require('utils');

				const rulepages = [{
					checker: {
						type: 'regurl',
						value: [
							/^https?:\/\/www\.wenku8\.(net|cc)\/novel\//,
							/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reader.php/,
						]
					},
					rules: [{
						type: 'selector',
						value: '#adv900'
					}, {
						type: 'selector',
						value: '#adv300'
					}, /*{
						type: 'selector',
						value: '#adbottom'
					},*/ {
						type: 'selector',
						value: '#adv1'
					},/* {
						type: 'selector',
						value: '#adtop'
					}*/]
				}, {
					checker: {
						type: 'regurl',
						value: [
							/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm/,
							/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php/,
							/^https?:\/\/www\.wenku8\.(net|cc)\/index\.php/,
						]
					},
					rules: [{
						type: 'selector',
						value: '.main.m_head>.h_banner>*'
					}]
				}];

				for (const page of rulepages) {
					if ((!page.checker || utils.testChecker(page.checker))) {
						for (const rule of page.rules) {
							deal(rule);
						}
					}
				}

				function deal(rule) {
					({
						'selector': function(selector) {
							detectDom({
								selector: selector,
								callback: elm => elm.remove(),
								once: false
							});
							//$(selector) && [...$All(selector)].forEach(elm => elm.remove());
						}
					})[rule.type](rule.value);
				}
			}
		},

		// Reader Enhance
		{
			name: '阅读增强',
			description: '在线阅读页面优化',
			id: 'ReaderEnhance',
			system: false,
			checker: {
				type: 'func',
				value: () => {
					return location.pathname.startsWith('/novel/') && location.pathname.split('/').pop() !== 'index.htm';
				}
			},
			func: function() {
				const SidePanel = require('SidePanel');

				const CONST = {
					Text: {
						PreviousChapter: '上一章',
						NextChapter: '下一章'
					},
					FontSizes: [8,10,12,14,16,18,24,30,36,42,48,54,60,70,80,90,100],
					FontColors: {
						'暗白': '#C8C8C8'
					}
				};

				// Wait till wenku8 script loaded, so detectDom is not enough
				$AEL(window, 'load', e => CSMwork());
				hotkeyJumppage();

				function CSMwork() {
					const CSM = new ConfigSetManager();
					CSM.install();

					sideButtons();
					moreFontColors();
					moreFontSizes();
					fontChanger();
					unsafeWindow.loadSet();

					function sideButtons() {
						SidePanel.insert({
							index: 2,
							faicon: 'fa-solid fa-chevron-left',
							tip: CONST.Text.PreviousChapter,
							onclick: e => $('#foottext>a:nth-of-type(3)').click()
						});
						SidePanel.insert({
							index: 2,
							faicon: 'fa-solid fa-chevron-right',
							tip: CONST.Text.NextChapter,
							onclick: e => $('#foottext>a:nth-of-type(4)').click()
						});
					}

					function moreFontColors() {
						const select = $('#txtcolor');

						// make options
						for (const [name, color] of Object.entries(CONST.FontColors)) {
							const option = $$CrE({
								tagName: 'option',
								props: {
									innerText: name,
									value: color
								}
							});
							select.appendChild(option);
						}
					}

					function moreFontSizes() {
						const select = $('#fonttype');
						const options = [...select.children];

						// make options
						for (const size of CONST.FontSizes) {
							const strSize = `${size}px`;
							const oriOption = options.find(o => o.value === strSize);
							if (oriOption) {
								// modify old option text
								oriOption.innerText = `${strSize}(${oriOption.innerText})`;
							} else {
								// make new option
								const option = $CrE('option');
								option.innerText = option.value = strSize;
								options.push(option);
							}
						}

						// sort options
						const getSizeNum = o => Math.floor(o.value.match(/\d+/)[0]);
						options.sort((o1, o2) => getSizeNum(o1) - getSizeNum(o2));

						// append to select
						options.forEach(o => select.appendChild(o));
					}

					// Provide font changer
					function fontChanger() {
						// Button
						const bcolor = $('#bcolor');
						const txtfont = $CrE('select');
						txtfont.id = 'txtfont';
						txtfont.addEventListener('change', e => CSM.ConfigSets.txtfont.set());
						bcolor.insertAdjacentElement('afterend', txtfont);
						bcolor.insertAdjacentText('afterend', '\t\t\t  字体选择');

						// Provided fonts
						const FONTS = [{"name":"默认","value":"unset"}, {"name":"微软雅黑","value":"Microsoft YaHei"},{"name":"黑体","value":"SimHei"},{"name":"微软正黑体","value":"Microsoft JhengHei"},{"name":"宋体","value":"SimSun"},{"name":"仿宋","value":"FangSong"},{"name":"新宋体","value":"NSimSun"},{"name":"细明体","value":"MingLiU"},{"name":"新细明体","value":"PMingLiU"},{"name":"楷体","value":"KaiTi"},{"name":"标楷体","value":"DFKai-SB"}]
						for (const font of FONTS) {
							const option = $CrE('option');
							option.innerText = font.name;
							option.value = font.value;
							txtfont.appendChild(option);
						}

						// Function
						CSM.ConfigSets.txtfont = {
							type: 'select',
							cookie: 'txtfont',
							default: 'unset',
							element: txtfont,
							effect: value => $('#content').style['font-family'] = value
						};
						CSM.initSet(CSM.ConfigSets.txtfont);

						// Load saved font
						CSM.ConfigSets.txtfont.load();
					}
				}

				function ConfigSetManager() {
					const CSM = this;
					CSM.initSet = initSet;
					CSM.saveSet = saveSet;
					CSM.loadSet = loadSet;
					CSM.install = install;

					// Config sets
					CSM.ConfigSets = {
						'bcolor': {
							type: 'select',
							cookie: 'bcolor',
							default: '#f6f6f6',
							element: unsafeWindow.bcolor,
							effect: value => document.bgColor = value
						},
						'txtcolor': {
							type: 'select',
							cookie: 'txtcolor',
							default: '#000000',
							element: unsafeWindow.txtcolor,
							effect: value => $('#content').style.color = value
						},
						'fonttype': {
							type: 'select',
							cookie: 'fonttype',
							default: '16px',
							element: unsafeWindow.fonttype,
							effect: value => $('#content').style.fontSize = value
						},
						'scrollspeed': {
							type: 'input',
							cookie: 'scrollspeed',
							default: 5,
							element: unsafeWindow.scrollspeed,
							effect: value => unsafeWindow.setSpeed()
						}
					};

					// Init Config sets
					for (const set of Object.values(CSM.ConfigSets)) {
						initSet(set);
					}

					function initSet(set) {
						set.get = () => ({'select': () => set.element.value, 'input': () => set.element.value})[set.type]();
						set.set = () => set.effect(set.get());
						set.save = () => unsafeWindow.setCookies(set.cookie, set.get());
						set.load = () => ({
							'select': () => {
								const savedVal = unsafeWindow.ReadCookies(set.cookie) || set.default;
								const index = [...set.element.options].findIndex(o => o.value === savedVal);
								set.element.selectedIndex = index;
								set.set();
							},
							'input': () => set.element.value = unsafeWindow.ReadCookies(set.cookie) || set.default
						})[set.type]();
					}

					function saveSet() {
						for (const [name, set] of Object.entries(CSM.ConfigSets)) {
							set.save();
						}
					};

					function loadSet() {
						for (const [name, set] of Object.entries(CSM.ConfigSets)) {
							set.load();
						}
					};

					function install() {
						Object.defineProperty(unsafeWindow, 'saveSet', {
							configurable: false,
							enumerable: true,
							value: CSM.saveSet,
							writable: false
						});
						Object.defineProperty(unsafeWindow, 'loadSet', {
							configurable: false,
							enumerable: true,
							value: CSM.loadSet,
							writable: false
						});
					};
				}

				// Make sure hotkey jumppage works
				function hotkeyJumppage() {
					$AEL(document, 'keydown', e => unsafeWindow.jumpPage && unsafeWindow.jumpPage());
				}
			}
		},

		// Bookcase Manager
		{
			name: '书架管理器',
			description: '书架功能增强',
			id: 'BookcaseManager',
			system: false,
			checker: {
				type: 'regurl',
				value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/bookcase.php(\?[\s\S]*)?$/,
			},
			func: function() {
				const autovote = FL_getFunction('autovote').enabled ? require('autovote') : null;
				const WenkuBlockGUI = require('WenkuBlockGUI');
				const mousetip = require('mousetip');
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');
				const CONST = {
					Text: {
						RemoveConfirm: '确实要将选中书目移出书架么?',
						RemoveConfirm_Title: '移出书架',
						EnterName: '设置此书架的名称为:',
						EnterName_Title: '设置名称',
						RenameHint: '双击我,为我取个好听的名字吧(ˊᗜˋ*)..',
						Moveto: '移动到 {NAME}',
						Autovote: '自动推书',
						VoteNow: '立即推书',
						TodayVoted: '今日已推书',
						TodayNotVoted: '今日尚未推书'
					},
					DomRes: {
						BookcaseWidth: ['3%', '19%', '9%', '25%', '20%', '9%', '5%', '10%']
					},
					Config_Ruleset: {
						'version-key': 'config-version',
						'ignores': ["LOCAL-CDN"],
						'defaultValues': {
							//'config-key': {},
							bookcaseNames: ['默认书架', '第1组书架', '第2组书架', '第3组书架', '第4组书架', '第5组书架']
						},
						'updaters': {
							/*'config-key': [
								function() {
									// This function contains updater for config['config-key'] from v0 to v1
								},
								function() {
									// This function contains updater for config['config-key'] from v1 to v2
								}
							]*/
						}
					}
				};
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				detectDom('#checkform', function() {
					mergeBookcase(function() {
						nameBookcase();
						autovoteGUI();
					});
				});

				function mergeBookcase(onAllMerged) {
					const curform = $('#checkform');
					const cur_classid = getFormClassid(curform);
					const AM = new AsyncManager();
					dealform(curform);
					for (let classid of [0,1,2,3,4,5]) {
						if (classid === cur_classid) {continue;}
						const url = `https://${location.host}/modules/article/bookcase.php?classid=${classid}${utils.getLang() ? '&charset=big5' : ''}`;
						utils.getDocument(url, pageload);
						AM.add();
					}
					AM.onfinish = onAllMerged;
					AM.finishEvent = true;

					function pageload(oDom) {
						const form = $(oDom, '#checkform');
						dealform(form);
						insertform(form);
						AM.finish();
					}

					function dealform(form) {
						form.onsubmit = check_confirm.bind(null, form);
					}

					function insertform(form) {
						const content = $('#content')
						const forms = [...$All(content, '#checkform'), form];
						forms.sort((f1, f2) => getFormClassid(f1) - getFormClassid(f2));
						forms.forEach(f => content.appendChild(f));
					}

					function check_confirm(form, e) {
						if (e.submitter.name !== 'btnsubmit') {return false;}
						const checknum = [...$All(form, 'input[name="checkid[]"]')].filter(input => input.checked).length;
						if (checknum == 0) {
							alertify.alert('请先选择要操作的书目!');
							return false;
						}
						const newclassid = $(form, '#newclassid');
						if (newclassid.value == -1) {
							alertify.confirm(CONST.Text.RemoveConfirm_Title, CONST.Text.RemoveConfirm, form.submit.bind(form), function() {});
							return false;
						} else {
							return true;
						}
					}
				}

				function nameBookcase() {
					const content = $('#content')
					for (const form of $All(content, '#checkform')) {
						deal(form);
					}

					function deal(form) {
						const titlebar = $(form, '.gridtop');
						const classid = getFormClassid(form);

						// Hide select
						$(form, 'select[name="classlist"]').style.display = 'none';

						// Display name
						titlebar.childNodes[0].nodeValue = CONFIG.bookcaseNames[classid];

						// Display name in select#newclassid
						[...$(form, '#newclassid').children].forEach(option => {
							const classid = Math.floor(option.value);
							if (classid >= 0) {
								option.innerText = replaceText(CONST.Text.Moveto, {'{NAME}': CONFIG.bookcaseNames[classid]});
							}
						});

						// Edit
						$AEL(titlebar, 'dblclick', function() {
							titlebar.style.userSelect = 'none';
							alertify.prompt(CONST.Text.EnterName_Title, CONST.Text.EnterName, CONFIG.bookcaseNames[classid], function(e, value) {
								CONFIG.bookcaseNames[classid] = value;
								titlebar.childNodes[0].nodeValue = value;
								titlebar.style.removeProperty('user-select');
							}, function() {
								titlebar.style.removeProperty('user-select');
							});
						});

						// Edit hint
						mousetip.settip(titlebar, CONST.Text.RenameHint);
					}
				}

				function autovoteGUI() {
					if (!autovote) {return false;}

					// Edit header tr
					for (const headTr of $All('tr[align="center"]')) {
						// Add vote column
						const th = $CrE('th');
						th.innerText = CONST.Text.Autovote;
						headTr.appendChild(th);

						// Modify width
						for (let i = 0; i < headTr.children.length; i++) {
							headTr.children[i].setAttribute('width', CONST.DomRes.BookcaseWidth[i]);
						}
					}

					// Edit book tr
					for (const bookTr of $All('#checkform tbody tr:not([align="center"]):not(:last-child)')) {
						const bookInfo = getBookInfo(bookTr);
						const td = $CrE('td');
						const input = $CrE('input');
						input.type = 'number';
						input.inputmode = 'numeric';
						input.style.width = '85%';
						input.setAttribute('form', '');
						input.value = autovote.getVoteNum(bookInfo.aid);
						$AEL(input, 'change', saveAutovotes.bind(input, bookInfo));
						td.style.textAlign = 'center';
						td.appendChild(input);
						bookTr.appendChild(td);
					}

					// add autovote block
					const left = $('#left');
					$(left, '.block:last-child').remove();
					const block = WenkuBlockGUI.makeBookcaseBlock({
						title: CONST.Text.Autovote,
						links: [{
							text: autovote.todayVoted() ? CONST.Text.TodayVoted : CONST.Text.TodayNotVoted,
							classes: CommonStyle.ClassName.Text,
							styles: {'cursor': 'default'}
						},{
							text: CONST.Text.VoteNow,
							listeners: [
								['click', e => autovote.checkRun(true)]
							],
							classes: CommonStyle.ClassName.Button
						}]
					});
					left.appendChild(block.block);

					function saveAutovotes(bookInfo, e) {
						const vote = Math.floor(this.value);
						vote >= 0 && autovote.setAutoVote({...bookInfo, vote});
						this.value = autovote.getVoteNum(bookInfo.aid);
					}

					function getBookInfo(bookTr) {
						return {
							aid: getUrlArgv($(bookTr.children[1], 'a[href*="readbookcase.php"]').href, 'aid'),
							name: $(bookTr.children[1], 'a[href*="readbookcase.php"]').innerText,
							author: $(bookTr, 'a[href*="authorarticle.php"]').innerText,
						}
					}
				}

				function getFormClassid(form) {
					return Math.floor($(form, 'select[name="classlist"]').value);
				}
			}
		},

		// Autovote
		{
			name: '自动推书',
			description: '每日自动按照设定好的次数和书籍完成[推一下]任务',
			id: 'autovote',
			system: false,
			checker: {
				type: 'switch',
				value: true
			},
			func: function() {
				const CONST = {
					URL: {
						Vote: `https://${location.host}/modules/article/uservote.php?id={AID}`
					},
					Text: {
						ConfirmTitle: '手动运行自动推书',
						ConfirmRepeatVote: '今天已运行过自动推书了,是否还要再次运行自动推书?',
						VoteTitle: '自动推书',
						VoteStart: '正在自动推书...',
						VoteFinish: '自动推书完毕,共完成{FINISH}次推书任务, 失败{ERROR}次</br>点击这里显示详情',
						VoteOverflow: '注意:当前设置的自动推书总数量({ALL})超出了账号每日最多推书数量({MAX})',
						VoteSaved: '已保存:每日推荐 {NAME} {NUM}次<br>当前总推荐次数为 {ALL} 次',
						VoteDeleted: '已设置:不再每日推荐 {NAME}<br>当前总推荐次数为 {ALL} 次',
						VoteNumLessThanZero: '不可设置每日推荐次数为负数!',
					},
					Config_Ruleset: {
						'version-key': 'config-version',
						'ignores': ["LOCAL-CDN"],
						'defaultValues': {
							//'config-key': {},
							autovotes: {},
							lastrun: '0000-00-00',
						},
						'updaters': {
							/*'config-key': [
								function() {
									// This function contains updater for config['config-key'] from v0 to v1
								},
								function() {
									// This function contains updater for config['config-key'] from v1 to v2
								}
							]*/
						}
					}
				}
				const utils = require('utils');

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				checkRun();
				return {todayVoted, checkRun, setAutoVote, getVoteInfo, getVoteNum};

				function checkRun(userConfirm=false) {
					if (!todayVoted()) {
						run();
					} else if (userConfirm) {
						alertify.confirm(CONST.Text.ConfirmTitle, CONST.Text.ConfirmRepeatVote, run, function() {});
					}
				}

				function todayVoted() {
					return utils.getTime('-', false) === CONFIG.lastrun;
				}

				function run() {
					const autovotes = CM.getConfig('autovotes');
					const all = Object.keys(autovotes).length;
					if (all) {
						const AM = new AsyncManager();
						let finish = 0, error = 0, results = [];
						alertify.notify(CONST.Text.VoteStart);

						for (const voteObj of Object.values(autovotes)) {
							const url = replaceText(CONST.URL.Vote, {'{AID}': voteObj.aid});
							for (let i = 0; i < voteObj.vote; i++) {
								request(url, e => {
									utils.parseDocument(e.response, oDoc => {
										try {
											results.push({
												voteObj,
												title: $(oDoc, '.blocktitle').innerText,
												content: $(oDoc, '.blockcontent>div:first-child').innerText
											});
											finish++; AM.finish();
										} catch(err) {
											error++; AM.finish();
										}
									});
								}, e => { error++; AM.finish(); });
								AM.add();
							}
						}

						AM.onfinish = function() {
							const message = replaceText(CONST.Text.VoteFinish, {'{FINISH}': finish, '{ERROR}': error});
							const box = alertify.success(message, undefined, clicked => {
								if (clicked) {
									const content = results.map(r => `[${r.voteObj.name}]<br>${r.title}<br>${r.content}`).join('<br><br>');
									alertify.alert(CONST.Text.VoteTitle, content);
								}
							});
							CONFIG.lastrun = utils.getTime('-', false);
						}
						AM.finishEvent = true;
					}
				}

				function setAutoVote(voteObj) {
					if (voteObj.vote > 0) {
						CONFIG.autovotes[voteObj.aid] = voteObj;
						notify(replaceText(CONST.Text.VoteSaved, { '{NAME}': voteObj.name, '{NUM}': voteObj.vote , '{ALL}': total() }));
					} else if (voteObj.vote === 0) {
						delete CONFIG.autovotes[voteObj.aid];
						notify(replaceText(CONST.Text.VoteDeleted, { '{NAME}': voteObj.name, '{ALL}': total() }));
					} else {
						alertify.warning(CONST.Text.VoteNumLessThanZero);
					}

					function notify(content) {
						if (setAutoVote.box) {
							setAutoVote.box.setContent(content);
							clearTimeout(setAutoVote.timer);
						} else {
							setAutoVote.box = alertify.message(content, 0);
						}
						setAutoVote.timer = setTimeout(() => {
							setAutoVote.box.dismiss();
							delete setAutoVote.box;
						}, 5000);
					}

					function total() {
						return Object.values(CONFIG.autovotes).reduce((all, obj) => all + obj.vote, 0);
					}
				}

				function getVoteInfo(aid) {
					return CONFIG.autovotes[aid];
				}

				function getVoteNum(aid) {
					const voteInfo = CONFIG.autovotes[aid];
					return voteInfo ? voteInfo.vote : 0;
				}

				function request(url, onload, onerror, retry=3) {
					tryReq(retry);

					function tryReq(retry=3) {
						GM_xmlhttpRequest({
							method: 'GET', url,
							responseType: 'blob',
							timeout: 15 * 1000,
							ontimeout: retry > 0 ? e => tryReq(retry-1) : onerror,
							onerror: retry > 0 ? e => tryReq(retry-1) : onerror,
							onload: onload
						});
					}
				}
			},
			setting: function() {
				window.open(`https://${location.host}/modules/article/bookcase.php`);
			}
		},

		// ReadingPlan
		{
			name: '稍后再读',
			description: '本地稍后再读',
			id: 'ReadingPlan',
			system: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/index\.php\/?/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php/
				]
			},
			func: function() {
				const WenkuBlockGUI = require('WenkuBlockGUI');
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');

				const CONST = {
					Text: {
						EmptyHint: '-- 稍后再读的书籍会显示在这里 --',
						IndexTitle: '稍后再读(拖动调整顺序)',
						ConfirmRemoval: '确定要将《<a href="{url}" class="{Class_Button}">{name}</a>》从稍后再读移除吗?',
						ReaditLater: '稍后再读',
						DontReaditLater: '移出稍后再读',
						Added: '已加入稍后再读',
						Removed: '已从稍后再读中移除',
						MoreThanDisplay: '您已经在稍后再读里面放了{N}本书了!</br>尽管稍后再读理论上可以存放无数本书,但是首页最多只能展示前10本哦~'
					},
					CSS: {
						Index: '.plus_rp_item {position: absolute; top: 0; right: 0; font-size: 2em; border: 1px dashed rgba(0,0,0,0); width: 1em; height: 1em;} .plus_rp_item:hover {border: 1px dashed rgba(0,0,0,0.3);}'
					},
					Config_Ruleset: {
						'version-key': 'config-version',
						'ignores': ["LOCAL-CDN"],
						'defaultValues': {
							//'config-key': {},
							books: [/*{
								aid: 1,
								name: '文学少女',
								url: 'https://www.wenku8.net/book/1.htm',
								cover: 'https://img.wenku8.com/image/1/1973/1973s.jpg'
								// array的排列顺序就是用户手动排列的顺序,默认由旧到新(新加书籍放后面)
							}*/]
						},
						'updaters': {
							/*'config-key': [
								function() {
									// This function contains updater for config['config-key'] from v0 to v1
								},
								function() {
									// This function contains updater for config['config-key'] from v1 to v2
								}
							]*/
						}
					}
				};

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				const functions = [{
					checker: {
						type: 'regurl',
						value: /^https?:\/\/www\.wenku8\.(net|cc)\/index\.php\/?/
					},
					func: function() {
						// Make block
						const block = WenkuBlockGUI.makeIndexCenterToplistBlock({
							title: CONST.Text.IndexTitle,
							books: CM.getConfig('books').filter((book, i) => i < 10)
						});

						// Top-right remove button
						for (const item of block.items) {
							item.b_container.style.position = 'relative';
							item.b_container.appendChild($$CrE({
								tagName: 'i',
								classes: ['fa-solid', 'fa-xmark', CommonStyle.ClassName.Button, 'plus_rp_item'],
								listeners: [['click', function(e) {
									alertify.confirm(
										replaceText(CONST.Text.ConfirmRemoval, {
											'{url}': item.url, '{name}': item.name,
											'Class_Button': CommonStyle.ClassName.Button
										}),
										function onok() {
											CONFIG.books.splice(CONFIG.books.findIndex(b => b.aid === item.aid), 1);
											block.items.splice(block.items.findIndex(b => b.aid === item.aid), 1);
											item.b_container.remove();
											alertify.success(CONST.Text.Removed);
										}
									)
								}]]
							}));
						}

						// Drag-drop to move books
						const sortable = new Sortable.default(block.blockcontent.children[0], {
							draggable: 'div'
						});
						sortable.on('sortable:sorted', function(e) {
							const div = e.dragEvent.data.originalSource;
							const books = moveItem(CM.getConfig('books'), e.oldIndex, e.newIndex);
							CONFIG.books = books;
							/* Wrong coding:
							The following code CANNOT WORK! Because CONFIG.books[index] saves the path that
							contains index, and always gives the current value of this path in GM_storage.
							As we know, moveItem removes arr[from] and then inserts it to arr[to], and
							as moveItem inserts(using arr.splice) CONFIG.books[oldIndex] into newIndex,
							the proxy for CONFIG.books[oldIndex]'s path is currently pointing to
							the new value at CONFIG.books[oldIndex], which is the next book of the
							dragging one! Tempermonkey will look into its(the next book's) props, and save
							them into disk, at CONFIG.books[newIndex], which should be storing the dragging
							book's properties.
							To avoid this from happenning, we get the pure object using CM.getConfig, sort it
							using moveItem, and then save it to GM_storage manually, just like code above.

							The wrong code is written below:
							moveItem(CONFIG.books, e.oldIndex, e.newIndex);
							*/
						});

						// Display hint while no books in reading plan
						if (!CONFIG.books.length) {
							const hint = $CrE('div');
							hint.innerText = CONST.Text.EmptyHint;
							hint.style.color = 'grey';
							hint.style.fontSize = '1.2em';
							block.blockcontent.style.display = 'flex';
							block.blockcontent.style.alignItems = 'center';
							block.blockcontent.style.justifyContent = 'center';
							block.blockcontent.appendChild(hint);
						}

						// Make container
						const container = $CrE('div');
						container.classList.add('main');
						container.appendChild(block.block);

						// Append to dom
						detectDom('#left', left => {
							document.body.insertBefore(container, left.parentElement.nextElementSibling);
						});

						// CSS
						detectDom('head', head => {
							addStyle(CONST.CSS.Index, 'plus-readingplan-index-css');
						})
					}
				}, {
					checker: {
						type: 'regurl',
						value: [
							/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm/,
							/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php/
						]
					},
					detectDom: '.main.m_foot',
					func: function() {
						const aid = getUrlArgv($('a[href*="/modules/article/uservote.php?"]').href, 'id');
						const btn = makeBtn();
						installBtn(btn);
						refreshBtn(btn);

						function makeBtn() {
							const btn = $CrE('span');
							btn.classList.add(CommonStyle.ClassName.Button);
							$AEL(btn, 'click', toggle);
							return btn;
						}

						function toggle() {
							const book = CONFIG.books.findIndex(book => book.aid == aid);
							if (book >= 0) {
								CONFIG.books.splice(book, 1);
								alertify.success(CONST.Text.Removed);
							} else {
								CONFIG.books.push({
									aid,
									name: $('#content table table b').innerText,
									url: location.href,
									cover: $('#content>div:first-child>table:nth-of-type(2) img').src
								});
								CONFIG.books.length > 10 && alertify.notify(replaceText(CONST.Text.MoreThanDisplay, {'{N}': CONFIG.books.length.toString()}));
								alertify.success(CONST.Text.Added);
							}
							refreshBtn(btn);
						}

						function refreshBtn(btn) {
							const text = CONFIG.books.some(book => book.aid == aid) ? CONST.Text.DontReaditLater : CONST.Text.ReaditLater;
							btn.innerText = text;
						}

						function installBtn(btn) {
							$('#content table table b').insertAdjacentElement('afterend', btn);
							btn.insertAdjacentText('beforebegin', '[');
							btn.insertAdjacentText('afterend', ']');
							return btn;
						}
					}
				}];

				return utils.loadFuncs(functions);

				function moveItem(arr, from, to) {
					const item = arr.splice(from, 1)[0];
					arr.splice(to, 0, item);
					return arr;
				}
			}
		},

		// Apipage Enhance
		{
			name: 'api页面增强',
			description: 'api页面增强',
			id: 'ApiEnhance',
			system: false,
			checker: {
				type: 'starturl',
				value: [
					`https://${location.host}/modules/article/addbookcase.php`,
					`https://${location.host}/modules/article/packshow.php`
				]
			},
			func: function() {
				const utils = require('utils');
				const CONST = {
					CSS: {
						Beautify: 'body>div:not([class*="ajs-"],[class*="alertify-"]) {display: flex; align-items: center; justify-content: center;}'
					}
				};

				document.readyState !== 'loading' ? work() : $AEL(document, 'DOMContentLoaded', work);

				function work() {
					if ($All('.block').length > 1) {
						return {};
					}
					addStyle(CONST.CSS.Beautify, 'plus-api-beautify');

					const functions = [{
						checker: {
							type: 'regurl',
							value: /\/addbookcase\.php/
						},
						detectDom: '.blocknote',
						func: function() {
							// Append link to bookcase page
							addBottomButton({
								href: `https://${location.host}/modules/article/bookcase.php`,
								innerHTML: '管理书架'
							});
						}
					}];

					return utils.loadFuncs(functions);
				}

				// Add a bottom-styled botton into bottom line, to the first place
				function addBottomButton(details) {
					const aClose = $('a[href="javascript:window.close()"]');
					const bottom = aClose.parentElement;
					const a = $CrE('a');
					const t1 = document.createTextNode('[');
					const t2 = document.createTextNode(']');
					const blank = $CrE('span');
					blank.innerHTML = ' ';
					blank.style.width = '0.5em';
					a.href = details.href;
					a.innerHTML = details.innerHTML;
					a.onclick = details.onclick;
					[blank, t2, a, t1].forEach((elm) => {bottom.insertBefore(elm, bottom.childNodes[0]);});
				}
			}
		},

		// Meta copy
		{
			name: '书籍信息复制',
			id: 'MetaCopy',
			description: '复制书籍信息:文库、作者、状态、最后更新、字数',
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm([\?#][\s\S]*)?$/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php([\?#][\s\S]*)?$/,
				]
			},
			func: function() {
				const CONST = {
					Text: {
						Copy: '[复制]',
						Copied: '已复制',
					}
				};

				const CommonStyle = require('CommonStyle');
				const mousetip = require('mousetip');
				const utils = require('utils');

				detectDom('#content>div:first-of-type>table+div:nth-child(2)', e => work());
				function work() {
					const metas = $All('#content>div:first-child>table:first-child>tbody>tr>td:not([colspan])');
					for (const meta of metas) {
					const data = meta.innerText.split(':')[1];
					const copy = $$CrE({
						tagName: 'span',
						props: {
							innerText: CONST.Text.Copy
						},
						styles: {
							'margin-left': '0.5em'
						},
						classes: CommonStyle.ClassName.Button,
						listeners: [['click', () => {
							copyText(data);
							mousetip.showtip(CONST.Text.Copied);
						}]]
					});
					mousetip.settip(copy, data);
					meta.appendChild(copy);
				}
				}

				// Copy text to clipboard (needs to be called in an user event)
				function copyText(text) {
					// Create a new textarea for copying
					const newInput = $CrE('textarea');
					document.body.appendChild(newInput);
					newInput.value = text;
					newInput.select();
					document.execCommand('copy');
					document.body.removeChild(newInput);
				}
			}
		},

		// Tag button
		{
			name: '书籍tag跳转',
			description: '书籍页面点击书籍tag跳转到对应的tag页面',
			id: 'TagButton',
			STOP: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm([\?#][\s\S]*)?$/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php([\?#][\s\S]*)?$/,
				]
			},
			CONST: {
				URL: {
					Tags: `https://${location.host}/modules/article/tags.php?t={tags}`
				}
			},
			func: function() {
				const CONST = FuncInfo.CONST;
				const CommonStyle = require('CommonStyle');
				const mousetip = require('mousetip');
				const utils = require('utils');
				detectDom('#content>div:first-of-type>table+div:nth-child(5)', e => work());

				function work() {
					const b = $('td[width="48%"]>.hottext:first-child>b');
					const text = b.innerText.split(/[︰:]/)[1];
					b.innerText = b.innerText.replace(text, '');

					text.split(' ').forEach((tag, i) => {
						i > 0 && b.insertAdjacentText('beforeend', ' ');
						b.appendChild($$CrE({
							tagName: 'a',
							props: {
								innerText: tag,
								href: replaceText(CONST.URL.Tags, { '{tags}': $URL.encode(tag) }),
								target: '_blank'
							},
							classes: [CommonStyle.ClassName.Button]
						}));
					});
				}
			}
		},

		// Single chapter download
		{
			name: '单章节下载',
			description: '单章节下载',
			id: 'ChapterDownload',
			checker: {
				type: 'func',
				value: () => {
					return (location.pathname.startsWith('/novel/') || location.pathname.match(/\/modules\/article\/reader.php/)) && !location.href.includes('index.htm');
				}
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const SidePanel = require('SidePanel');
				const utils = require('utils');

				const CONST = {
					Text: {
						DownloadChapter: '下载本章',
						DownloadStatus: '下载中({Fin}/{All})',
						AllImagesDownloaded: '全部图片下载完毕',
						ImageDownloadDone: '图片下载已完成</br>成功:{Suc} 张</br>失败:{Fail} 张</br>下载失败的图片分别是第 {Fail_No} 张</br>所有下载失败的图片均已经过3次自动重试',
						Downloaded: '已下载',
						ImageDownloadError: '第 {N} 张图片下载失败',
					}
				};

				detectDom('#footlink', e => work());
				function work() {
					const headlink = $('#headlink');
					const linkleft = $('#linkleft');
					const linkright = $('#linkright');
					headlink.style.position = 'relative';
					linkright.style.position = 'absolute';
					linkright.style.right = '0';
					linkright.style.width = 'unset';

					const dlBtn = $$CrE({
						tagName: 'span',
						props: {
							innerText: CONST.Text.DownloadChapter,
							id: 'plus-chapter-dl'
						},
						classes: CommonStyle.ClassName.Button,
						listeners: [['click', download]]
					});
					linkright.lastElementChild.insertAdjacentHTML('afterend', ' | ');
					linkright.insertBefore(dlBtn, linkright.lastChild);

					SidePanel.insert({
					index: 2,
					tip: CONST.Text.DownloadChapter,
					faicon: 'fa-solid fa-download',
					onclick: download
				});
				}

				function download() {
					dText();
					dImg();
				}

				function dText() {
					const title = $('#title').innerText;
					const text = $('#content').innerText;
					const filename = title + '.txt';
					text.trim().length > 0 && !$('#title').innerText.includes(text.trim()) && utils.downloadText(text, filename);
				}

				function dImg() {
					if (!$('.divimage')) {return false;}

					const imgs = [...$All('.divimage img')];
					const title = $('#title').innerText;
					const AM = new AsyncManager();
					let finished = 0, failed = 0, fails = [];
					imgs.forEach((img, i) => {
						AM.add();
						dl(img, i);
					});
					AM.onfinish = onfinish;
					AM.finishEvent = true;

					function dl(img, index, r=3) {
						const onfail = (err, reason) => r ? dl(img, index, r-1) : fail(err, reason);
						const ext = (() => {
							const str = img.src.split('.').pop();
							const exts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
							return str && exts.includes(str.toLowerCase()) ? str : 'jpg';
						}) ();
						dl_GM({
							url: img.src,
							name: `${title}_${(index+1).toString().padStart('0', imgs.length.toString().length)}.${ext}`,
							onload: function() {
								$('#plus-chapter-dl').innerText = replaceText(CONST.Text.DownloadStatus, {
									'{Fin}': (++finished).toString(),
									'{All}': imgs.length
								});
								AM.finish();
							},
							onerror: err => onfail(err, 'onerror'),
							ontimeout: err => onfail(err, 'ontimeout'),
						});

						function fail(err, reason) {
							failed++;
							fails.push({img, index, err, reason});
							DoLog(LogLevel.Error, [`Image download error: ${reason}`, err]);
							alertify.error(replaceText(CONST.Text.ImageDownloadError, {
								'{N}': (index + 1).toString()
							}));
							AM.finish();
						}
					}

					function onfinish() {
						const all = imgs.length;
						if (finished === all) {
							alertify.success(CONST.Text.AllImagesDownloaded);
							setTimeout(e => $('#plus-chapter-dl').innerText = CONST.Text.Downloaded, 3000);
						} else if (finished + failed === all) {
							alertify.notify(replaceText(CONST.Text.ImageDownloadDone, {
								'{Suc}': finished.toString(),
								'{Fail}': failed.toString(),
								'{Fail_No}': fails.map(f => (f.index+1).toString()).join(',')
							}));
						}
					}
				}
			}
		},

		// Darkmode
		{
			name: '深色模式',
			description: '可单独开关、可跟随系统配置的深色模式',
			id: 'darkmode',
			CONST: {
				Text: {
					Toggle: '切换浅色/深色模式',
					AlertTitle: `深色模式设置`,
					autoMatchOsTheme: '深色模式自动跟随系统配置'
				},
				PageCSS: [
					// Sidepanel
					{
						id: 'sidepanel',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode .sidepanel-button {background-color: #333333;color: #6f9ff1;fill: #6f9ff1;}.plus-darkmode .sidepanel-button:hover {background-color: #6f9ff1;color: #333333;fill: #333333;}'
					},

					// Mouse tip
					{
						id: 'mousetip',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode #tips {background-color: #333333;color: white;border: 1px solid #0d548b;}'
					},

					// Common style darkend
					{
						id: 'commonstyle',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode .plus_text {color: #6f9ff1 !important;}.plus-darkmode .plus_button.plus_disabled {color: #888888;}'
					},

					// .block
					{
						id: 'block',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode :is(#left, #right, *) .blockcontent {background-color: #222222;}.plus-darkmode :is(#left, #right, *) .blocknote {background-color: #282828;}.plus-darkmode :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *, .ultop li) {color: #6f9ff1;}.plus-darkmode :is(#left, #right, #centers, *) .blocktitle>:is(.txt, .txtr) {background-color: #383838;line-height: 27px;padding-top: 0;}.plus-darkmode .block {border: 1px solid #0d548b;}.plus-darkmode :is(.blocktitle, .blockcontent, .blocknote) {border: none;}.plus-darkmode .block :is(.ultop li, .ultops li) {border-bottom: 1px dashed #0d548b;}'
					},

					// header and footer
					{
						id: 'headfoot',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode :is(.main.m_top, .nav, .navinner, .navlist, .nav li, :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *)) {background: #333333;}.plus-darkmode :is(.nav a.current, .nav a:hover, .nav a:active) {background: #444444;}.plus-darkmode .m_foot {border-top: 1px dashed #0d548b;border-bottom: 1px dashed #0d548b;}'
					},

					// elements (input textarea .button scrollbar, etc)
					{
						id: 'element',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode {color: #C8C8C8;background-color: #222222;}.plus-darkmode :is(.even, .odd) {background-color: #222222;}.plus-darkmode table.grid td {background-color: #222222 !important;}.plus-darkmode :is(input:not([type]:not([type="text"], [type="number"], [type="file"], [type="password"])), textarea, .plus_list_item, button) {background-color: #333333;color: #DDDDDD;}.plus-darkmode :is(.button, input[type="button"]) {color: #C8C8C8;background-color: #333333;}.plus-darkmode select {color: #AAAAAA;background-color: #333333;}.plus-darkmode :is(.hottext, a.hottext) {color: #f36d55;}.plus-darkmode :is(.button, select, textarea, input:not(.plus_list_item>input, .UBB_ColorList input), .plus_list_item):not(:disabled) {border: 2px solid #0d548b;}.plus-darkmode :is(input, textarea, button):disabled {border: 2px solid #444444;}.plus-darkmode a {color: #AAAAAA;}.plus-darkmode a:hover {color: #4a8dff;}.plus-darkmode a:is(.ultop li a, .poptext, a.poptext, .ultops li a) {color: #f36d55;}.plus-darkmode :is(table.grid caption, .gridtop, table.grid th, .head) {border: 1px solid #0d548b;background: #333333;color: #6f9ff1;}.plus-darkmode :is(table.grid, table.grid td) {border: 1px solid #0d548b;}.plus-darkmode input[type="checkbox"]::after {background-color: #333333;}.plus-darkmode :is(.pagelink, .pagelink a:hover) {background-color: #333333;color: #6f9ff1;}.plus-darkmode .pagelink strong {background-color: #444444;}.plus-darkmode .pagelink em {border-right: 1px solid #0d548b;}.plus-darkmode .pagelink kbd {border-left: 1px solid #0d548b;}.plus-darkmode .pagelink {border: 1px solid #0d548b;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement) {scrollbar-color: #444444 #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement):hover {scrollbar-color: #484848 #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar {background-color: #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-corner {background-color: #333333;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb, .plus-darkmode *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button {background-color: #444444;}:is(.plus-darkmode, .plus-darkmode *):not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-thumb:hover, .plus-darkmode *:not(#NotAElement>#NotAElement>#NotAElement>#NotAElement)::-webkit-scrollbar-button:hover {background-color: #484848;}'
					},

					// dialog
					{
						id: 'dialog',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode #dialog {color: #C8C8C8;background-color: #222222;border: 5px solid #0d548b;}.plus-darkmode #dialog a[onclick="closeDialog()"] {border: 1px solid #0d548b !important;outline: thin solid #0d548b !important;}'
					},

					// alertify
					{
						id: 'alertify',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode .alertify :is(.ajs-dialog, .ajs-header, .ajs-footer) {background-color: #222222;}.plus-darkmode .alertify :is(.ajs-header, .ajs-body) {color: #C8C8C8;}.plus-darkmode .alertify .ajs-header {border-bottom: 1px solid #333333;}.plus-darkmode .alertify .ajs-footer {border-top: 1px solid #333333;}.plus-darkmode .alertify .ajs-footer .ajs-buttons .ajs-button.ajs-cancel {color: #C8C8C8;}.plus-darkmode .plus_func_block {border-top: 1px solid #333333;}.plus-darkmode .plus_func_sysalert {background-color: #40331f;color: #cd8c32;}.plus-darkmode .alertify-notifier .ajs-message {border: 1px solid #0d548b;}.plus-darkmode .ajs-message{background-color: #222222;color: #C8C8C8;}.plus-darkmode .ajs-message.ajs-success {background-color: #1f6a01;}.plus-darkmode .ajs-message.ajs-warning {background-color: #5f4e05;}.plus-darkmode .ajs-message.ajs-error {background-color: #730808;}'
					},

					// replyarea
					{
						id: 'replyarea',
						checker: {
							type: 'starturl',
							value: [
								// Page: reviews list
								`https://${location.host}/modules/article/reviews.php`,

								// Page: review
								`https://${location.host}/modules/article/reviewshow.php`,

								// Page: review edit
								`https://${location.host}/modules/article/reviewedit.php`,

								// Page: book
								`https://${location.host}/book`,
								`https://${location.host}/modules/article/articleinfo.php`,
							]
						},
						css: '.plus-darkmode form[name="frmreview"] caption {background: #333333;color: #6f9ff1;border: 1px solid #0d548b;}'
					},

					// index.php
					{
						id: 'index',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\/index\.php([\?#][\s\S]*)?$/
						},
						css: 'body.plus-darkmode, .plus-darkmode :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) {background-color: #222222;color: #C8C8C8;}.plus-darkmode :is(#left, #right, #centers, *) :is(.blocktitle, .blocktitle *) a {color: #AAAAAA;}a[href^="http://tieba.baidu.com"] {color: #4a8dff !important;}',
					},

					// Book
					{
						id: 'book',
						checker: {
							type: 'regurl',
							value: [
								/^https?:\/\/www\.wenku8\.(net|cc)\/book\/\d+\.htm([\?#][\s\S]*)?$/,
								/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/articleinfo\.php([\?#][\s\S]*)?$/,
							]
						},
						css: 'body.plus-darkmode, .plus-darkmode :is(:is(#left, #right, #centers) .blockcontent, .blockcontent, .odd, .even) {background-color: #222222;color: #C8C8C8;}.plus-darkmode table.grid td {background-color: #222222 !important;}.plus-darkmode table.grid:not(form table) tr:first-of-type>td:nth-of-type(2n+1) {background-color: #333333 !important;}.plus-darkmode table.grid:not(form table, #content .main>table:first-of-type) tr:first-of-type>td:first-of-type {color: #6f9ff1;}.plus-darkmode fieldset {border: 2px solid #0d548b;}.plus-darkmode :is(table.grid, table.grid td, table.grid caption, .gridtop) {border: 1px solid #0d548b;}'
					},

					// Book index
					{
						id: 'bookindex',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\/novel\/\d+\/\d+\/index\.htm([\?#][\s\S]*)?$/
						},
						css: 'body.plus-darkmode, .plus-darkmode :is(.css, .vcss, .ccss) {background-color: #222222;color: #C8C8C8;}.plus-darkmode #headlink {border-bottom: 1px solid #0d548b;border-top: 1px solid #0d548b;}.plus-darkmode :is(.css, .vcss, .ccss) {border: 1px solid #0d548b;border-collapse: collapse;}'
					},

					// Novel
					{
						id: 'novel',
						checker: {
							type: 'func',
							value: () => {
								return location.pathname.startsWith('/novel/') && location.pathname.split('/').pop() !== 'index.htm';
							}
						},
						css: '.plus-darkmode a {color: #4a8dff;}'
					},

					// Reviewshow
					{
						id: 'reviewshow',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/
						},
						css: 'body.plus-darkmode, .plus-darkmode :is(:not(*)) {background-color: #222222;color: #C8C8C8;}.plus-darkmode table.grid td {background-color: #222222;}.plus-darkmode :is(#content table.grid hr, #content>table:nth-of-type(2) th, #pagelink) {border: 1px solid #0d548b;}.plus-darkmode :is(.jieqiQuote, .jieqiCode, .jieqiNote) {background-color: #282828;color: #6f9ff1;border: 1px solid #0d548b;}'
					},

					// Beautifier
					{
						id: 'beautifier',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: ''
					},

					// tippy
					{
						id: 'tippy',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: '.plus-darkmode .tippy-box[data-theme~="wenku_tip"] {background-color: #282828;color: #C3C3C3;border: 1px solid #0d548b;}.plus-darkmode .tippy-box[data-theme~="wenku_tip"][data-placement^="top"]>.tippy-arrow::before {border-top-color: #0d548b;}.plus-darkmode .tippy-box[data-theme~="wenku_tip"][data-placement^="left"]>.tippy-arrow::before {border-left-color: #0d548b;}.plus-darkmode .tippy-box[data-theme~="wenku_tip"][data-placement^="right"]>.tippy-arrow::before {border-right-color: #0d548b;}.plus-darkmode .tippy-box[data-theme~="wenku_tip"][data-placement^="bottom"]>.tippy-arrow::before {border-bottom-color: #0d548b;}'
					},

					// frmreview
					{
						id: 'frmreview',
						checker: {
							type: 'starturl',
							value: [
								// Page: reviews list
								`https://${location.host}/modules/article/reviews.php`,

								// Page: review
								`https://${location.host}/modules/article/reviewshow.php`,

								// Page: review edit
								`https://${location.host}/modules/article/reviewedit.php`,

								// Page: book
								`https://${location.host}/book`,
								`https://${location.host}/modules/article/articleinfo.php`,
							]
						},
						css: '.plus-darkmode .UBB_FontSizeList li {border: 1px solid #0d548b;}.plus-darkmode .UBB_ColorList :is(table, table td) {border: 1px solid #0d548b;}.plus-darkmode .UBB_ColorList {background-color: #222222;}'
					},

					/* Template
					{
						id: '',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\//
						},
						css: ''
					},
					*/
				],
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						darkmode: false,
						autoMatchOsTheme: false,
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				},
			},
			STOP: false,
			func: function() {
				// Stop running in image, code, or pure text papes
				if (/\.(jpe?g|png|webp|gif|bmp|txt|js|css)/.test(location.pathname)) {
					return false;
				}

				const SPanel = require('SidePanel');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				/* Darkmode.js doing pretty bad with background image beautifier on, so stop using it
				// Uses darkmode.js
				// See https://github.com/sandoche/Darkmode.js
				const options = {
					saveInCookies: false, // default: true,
					label: '', // default: ''
					autoMatchOsTheme: false // default: true // This doesn't work in my computer, use my own code instead
				}

				detectDom('body', function() {
					const darkmode = new Darkmode(options);
					const button = SPanel.insert({
						index: 2,
						faicon: 'fa-solid fa-circle-half-stroke',
						tip: CONST.Text.Toggle,
						onclick: e => darkmode.toggle()
					});
					if (CONFIG.autoMatchOsTheme) {
						const match = window.matchMedia('(prefers-color-scheme: dark)');
						$AEL(match, 'change', followOsTheme);
						followOsTheme(match);
					}

					function followOsTheme(e) {
						e.matches && !darkmode.isActivated() && darkmode.toggle();
						return e.matches;
					}
				});
				*/

				// Append css
				CONST.PageCSS.filter(cssObj => utils.testChecker(cssObj.checker))
					.forEach(cssObj => addStyle(cssObj.css, `plus-darkmode-${cssObj.id}`));

				// Side Panel toggle button
				const button = SPanel.insert({
					index: 2,
					faicon: 'fa-solid fa-circle-half-stroke',
					tip: CONST.Text.Toggle,
					onclick: e => toggle()
				});

				// Follow os theme
				detectDom('body', function() {
					if (CONFIG.autoMatchOsTheme) {
						const match = window.matchMedia('(prefers-color-scheme: dark)');
						$AEL(match, 'change', followOsTheme);
						followOsTheme(match);
					} else {
						CONFIG.darkmode !== isActivated() && toggle();
					}
				});

				function followOsTheme(mediaMatch) {
					const isOsDarkmode = mediaMatch.matches;
					isOsDarkmode !== isActivated() && toggle();
					return isOsDarkmode;
				}

				function toggle() {
					document.body.classList[isActivated() ? 'remove' : 'add']('plus-darkmode');
					CONFIG.darkmode = isActivated();
				}

				function isActivated() {
					return document.body.classList.contains('plus-darkmode');
				}
			},
			setting: function setter() {
				const SettingPanel = require('SettingPanel');
				const CONST = FuncInfo.CONST;

				const CM = new ConfigManager(CONST.Config_Ruleset);
				CM.updateAllConfigs();

				SettingPanel.easySettings({
					title: CONST.Text.AlertTitle,
					items: [{
						text: CONST.Text.autoMatchOsTheme,
						path: 'autoMatchOsTheme',
						type: 'boolean'
					}]
				}, CM);
			}
		},

		// ReviewEnchance
		{
			name: '书评吐槽增强',
			description: '书评吐槽页面提供引用回复、页面内编辑回帖内容、页面自动刷新等等增强功能',
			id: 'ReviewEnhance',
			STOP: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/,
				]
			},
			CONST: {
				Text: {
					Quote: '引用',
					QuoteNum: ['或者,', '仅引用序号'],
					AutoRefresh_Off: '自动刷新',
					AutoRefresh: '自动刷新({t}秒)',
					FloorContentModified: '(内容已更新)',
					ReplyErrorTitle: '回复发生错误',
					DownloadFormat: '保存格式:',
					FormatTip: '纯文本:</br>目前所提供的几种格式中,一般而言最适合阅读的格式。但是,这种格式仅能保存文本内容。</br></br>BBCODE格式:</br>即文库评论的代码格式,相当于引用楼层时自动填入回复框的内容。保存为此格式可以一定程度上在保持可阅读性的同时,保留排版及多媒体信息。</br></br>JSON格式:</br>一种方便程序读取的格式,包含所有脚本能够获取到的文本、排版和多媒体信息。</br>需要注意的是,这种格式一般来讲不适合直接阅读。',
					DownloadPost: '下载本贴(共{N}页)',
					DownloadingPost: '正在下载({finished}/{all})',
					DownloadFailed: '下载失败,请重试',
					DownloadSuccess: '下载完毕',
					DownloadFormats: {
						text: {
							name: '纯文本',
							ext: 'txt'
						},
						bbcode: {
							name: 'BBCODE格式',
							ext: 'txt'
						},
						json: {
							name: 'JSON',
							ext: 'json'
						}
					},
					DownloadTemplate: {
						Template: `轻小说文库 书评吐槽 [ID: {Review ID}]\n主题:{Review Title}\n保存自:{Review URL}\n保存时间:{Download Time}\n保存格式:{Format}\nBy ${GM_info.script.name} Ver. ${GM_info.script.version} author ${GM_info.script.author}\n\n\n\n\n\n{Floor Content}`,
						FloorTemplate: `[{FloorNum}#] [{Username} {UserID}]{Floor Title} [{Time}]\n{Floor Content}`,
						FloorTitleTemplate: ` [{Title}]`,
						FloorDelimiter: '\n\n\n\n———— - ———— - ———— - ————\n',
					}
				},
				Number: {
					RefreshInterval: 20 // time by second
				},
				URL: {
					ReviewShow: `https://${location.host}/modules/article/reviewshow.php?rid={RID}&page={Page}`
				},
				Selector: {
					Floor: '#content>table:not(:is(:last-of-type, :first-of-type, :nth-of-type(2), :nth-last-of-type(2):nth-last-child(2)))',
					FloorA: 'tbody>tr>td:nth-of-type(2)>div:nth-of-type(2)>a[href^="#yid"]',
					PagesTable: '#content>table:is(:last-of-type:not(:last-child),:nth-last-of-type(2):nth-last-child(2))',
					PagesTr: '#content>table:is(:last-of-type:not(:last-child),:nth-last-of-type(2):nth-last-child(2))>tbody>tr',
					ReplyTable: '#content>form>table',
					FloorContent: 'hr+div',
					FloorTitle: 'tbody>tr>td:nth-of-type(2)>div:first-of-type>strong',
					FloorTitleRight: 'tbody>tr>td:nth-of-type(2)>div:nth-of-type(2)'
				},
				CSS: {
					Common: '#content>table:not(:is(:last-of-type, :first-of-type, :nth-of-type(2), :nth-last-of-type(2):nth-last-child(2)))>tbody>tr>td:last-of-type>:is(div:nth-of-type(1),div:nth-of-type(2)) {width: unset !important;}'
				},
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						downloadFormat: 'bbcode',
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				},
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const ReplyAreaEnhance = require('ReplyAreaEnhance');
				const mousetip = require('mousetip');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				const STORAGE = {}; // Public storage for all functions

				// Function for the page.
				// This shouldn't rely on any floor elements, because floor elements might be removed or modified
				const basicFuncs = [
					function css() {
						addStyle(CONST.CSS.Common, 'plus_review_css');
					},
					function linkJump() {
						const content = $('#content');
						$AEL(content, 'click', function(e) {
							const links = [...$All(content, 'table>tbody>tr>td:nth-of-type(2)>div:nth-of-type(2)>a[href^="#yid"]')];
							const getYID = a => a.href.match(/#yid\d+/)[0];
							const linkObjs = links.map(a => ({ a, yid: getYID(a) }));
							const isReplyLink = elm => elm.tagName === 'A' && elm.pathname === '/modules/article/reviewshow.php' &&
								  elm.host === location.host && elm.href.includes('#yid') && !links.includes(elm);
							if (isReplyLink(e.target)) {
								const a = e.target;
								const linkObj = linkObjs.find(obj => obj.yid === getYID(a));
								if (linkObj) {
									destroyEvent(e);
									linkObj.a.click();
								}
							}
						})
					},
					function autoRefresh() {
						let timer, t=1;

						// GUI
						const container = $$CrE({
							tagName: 'td',
							styles: {
								'text-align': 'left'
							}
						});
						const checkbox = $$CrE({
							tagName: 'input',
							props: {
								type: 'checkbox'
							},
							styles: {
								'vertical-align': 'middle',
							},
							listeners: [['change', function(e) {
								checkbox.checked ? start() : stop();
							}]]
						});
						const text = $$CrE({
							tagName: 'span',
							props: {
								innerText: CONST.Text.AutoRefresh_Off
							},
							styles: {
								'margin-left': '1em',
								'vertical-align': 'middle',
								'cursor': 'default',
							},
							listeners: [['click', function(e) {
								checkbox.click();
							}]]
						});
						container.appendChild(checkbox);
						container.appendChild(text);
						$(CONST.Selector.PagesTr).insertAdjacentElement('afterbegin', container);

						function start() {
							// When start, set current waiting time to 1s
							// Not 0s, in order to avoid someone frequently refreshing by clicking the checkbox again and again
							t = 1;
							timer = setInterval(countdown, 1000);
							text.innerText = replaceText(CONST.Text.AutoRefresh, {'{t}': t.toString()});
						}

						function stop() {
							text.innerText = CONST.Text.AutoRefresh_Off;
							clearInterval(timer);
						}

						function countdown() {
							if (--t < 0) {
								refresh();
								t = CONST.Number.RefreshInterval;
							}
							text.innerText = replaceText(CONST.Text.AutoRefresh, {'{t}': t.toString()});
						}
					},
					function replyInPage() {
						FL_recieveMessage('ReplySent', dealResponse, 'ReplyAreaEnhance');
						FL_recieveMessage('ReplyFailed', data => refresh(), 'ReplyAreaEnhance');

						function dealResponse(data) {
							const blob = data.response.response;
							const dom = utils.parseDocument(blob, function(dom) {
								const redirector = $(dom.head, 'meta[http-equiv="refresh"]');
								if (dom.body.childElementCount === 1 && !redirector) {
									// Error page
									const closeBtn = $(dom, 'a[href="javascript:window.close()"]');
									closeBtn.href = 'javascript: void(0);';
									$AEL(closeBtn, 'click', e => altbox.close());
									const altbox = alertify.alert(CONST.Text.ReplyErrorTitle, $(dom, '.block'));

									// Refresh with current page when reply returns an error
									refresh(getUrlArgv({
										url: location.href,
										name: 'page',
										defaultValue: '1'
									}));
								} else if (dom.body.childElementCount === 1 && redirector) {
									// Reply modification successed
									data.form.reset();
									$('#dialog') && unsafeWindow.closeDialog();

									// Refresh with current page when reply returns an error
									refresh(getUrlArgv({
										url: location.href,
										name: 'page',
										defaultValue: '1'
									}));
								} else {
									// Reply send successed
									data.form.reset();

									// Refresh to last page while successfully sent a new reply
									refresh();
								}
							});
						}
					},
					function reviewDownload() {
						// Adjust element width
						[...$All('#content>table:first-of-type td td')].forEach(td => td.style.width = 'unset');

						// Make button
						const container = $CrE('span');
						const formatText = $$CrE({
							tagName: 'span',
							props: {
								innerText: CONST.Text.DownloadFormat
							},
							styles: {
								'vertical-align': 'middle',
								'margin-right': '1em',
								'padding-left': '0.5em',
								'cursor': 'default',
							}
						});
						const formatChooser = $$CrE({
							tagName: 'select',
							styles: {
								'vertical-align': 'middle',
								'margin-right': '0.5em',
							},
							listeners: [['change', e => CONFIG.downloadFormat = formatChooser.value]]
						});
						const button = $$CrE({
							tagName: 'span',
							classes: [CommonStyle.ClassName.Button],
							props: {
								innerText: replaceText(CONST.Text.DownloadPost, {'{N}': $('#pagelink .last').innerText})
							},
							styles: {
								'vertical-align': 'middle',
							},
							listeners: [['click', e => download(formatChooser.value)]]
						});
						for (const [value, prop] of Object.entries(CONST.Text.DownloadFormats)) {
							formatChooser.appendChild($$CrE({
								tagName: 'option',
								props: {
									value: value,
									innerText: prop.name
								}
							}));
						}
						formatChooser.value = CONFIG.downloadFormat;
						//mousetip.settip(formatText, CONST.Text.FormatTip);
						//mousetip.settip(formatChooser, CONST.Text.FormatTip);
						tippy(container, {
							content: $$CrE({
								tagName: 'div',
								props: {
									innerHTML: CONST.Text.FormatTip
								},
							}),
							theme: 'wenku_tip',
							placement: 'bottom',
						});
						[formatText, formatChooser, button].forEach(elm => container.appendChild(elm));
						$('#content>table:first-of-type td td:last-of-type').appendChild(container);

						function download(format) {
							// Temporarily use current document's last page text as maxPage
							let maxPage = parseInt($('#pagelink .last').innerText, 10);
							let finished = 0;

							// Set button text
							button.innerText = replaceText(CONST.Text.DownloadingPost, {'{finished}': '0', '{all}': maxPage.toString()});

							// Get review's id
							const rid = getUrlArgv('rid');

							// Result json object
							const review = {
								rid,
								title: '',
								floors: [],
							};

							// Request all review pages
							const AM = new AsyncManager();
							for (let page = 1; page <= maxPage; page++) {
								getPage(AM, rid, page, downloadFailed);
							}

							// When all request finished, merge result
							AM.onfinish = e => {
								output(format);
								button.innerText = CONST.Text.DownloadSuccess;
							}
							AM.finishEvent = true;

							// Request specified page of specified review and concat all floors' data, with AM task management
							function getPage(AM, rid, page, onerror, retryCount=3) {
								// Append AM task
								AM.add();

								// Request specified page of specified review
								const url = replaceText(CONST.URL.ReviewShow, {'{RID}': getUrlArgv('rid'), '{Page}': page.toString()})
								utils.getDocument({url, callback: function(doc) {
									// Append floors' json into review.floors
									const data = getPageData(doc);
									review.floors.push.apply(review.floors, data);

									// Update button text
									button.innerText = replaceText(CONST.Text.DownloadingPost, {'{finished}': (++finished).toString(), '{all}': maxPage.toString()});

									// Finish AM task
									AM.finish();
								}, onerror: retry});

								function retry(err) {
									if (--retryCount > 0) {
										DoLog(`Review download: Retrying ${url}, retryCount=${retryCount}`);
										getPage(AM, rid, page, onerror, retryCount);
									} else {
										if (typeof onerror === 'function') {
											// onerror shouldn't throw any error. onerror first then finish task
											onerror(err);
											AM.finish();
										} else {
											// No onerror, just finish task then throw the error
											AM.finish();
											Err(`Review download: download failed after all retrying.`);
										}
									}
								}
							}

							// Returns an array of floors' data
							function getPageData(doc) {
								// Record realtime pages count
								maxPage = parseInt($('#pagelink .last').innerText, 10);

								// Parse floors' json
								const floors = parseFloors(doc);
								const data = floors.map(floor => jsonFloor(floor));

								return data;
							}

							// Returns a json of a floor's data
							// Return: {username, userid, title, time, floorNum, contentText, contentBBCode}
							function jsonFloor(floor) {
								const userA = $(floor, '.avatar+br+strong>a');
								const username = userA.innerText;
								const userid = getUrlArgv(userA.href, 'uid');
								const title = $(floor, CONST.Selector.FloorTitle).innerText;
								const time = $(floor, CONST.Selector.FloorTitleRight).innerText.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)[0];
								const floorNum = parseInt($(floor, CONST.Selector.FloorA).innerText.match(/\d+/)[0], 10);
								const contentText = $(floor, CONST.Selector.FloorContent).innerText;
								const contentBBCode = getFloorContent($(floor, CONST.Selector.FloorContent));
								return {username, userid, title, time, floorNum, contentText, contentBBCode};
							}

							// Output in specified format
							function output(format) {
								// Sort floors
								review.floors.sort((f1, f2) => f1.floorNum - f2.floorNum);

								// Set review title as first floor's title
								review.title = review.floors[0].title;

								// Get blob and filename in format
								const blob = ({
									text: outputText,
									bbcode: outputBBCode,
									json: outputJson,
								})[format]();
								const filename = `${review.rid} - ${review.title}(${CONST.Text.DownloadFormats[format].name}).${CONST.Text.DownloadFormats[format].ext}`;

								// Output blob
								const url = URL.createObjectURL(blob);
								$$CrE({
									tagName: 'a',
									attrs: {
										href: url,
										download: filename
									}
								}).click();
								setTimeout(e => URL.revokeObjectURL(url), 0);

								function outputText() {
									const floorText = review.floors.map(floordata => replaceText(CONST.Text.DownloadTemplate.FloorTemplate, {
										'{FloorNum}': floordata.floorNum,
										'{Username}': floordata.username,
										'{UserID}': floordata.userid,
										'{Time}': floordata.time,
										'{Floor Title}': floordata.title ? replaceText(CONST.Text.DownloadTemplate.FloorTitleTemplate, {'{Title}': floordata.title}) : '',
										'{Floor Content}': floordata.contentText
									})).join(CONST.Text.DownloadTemplate.FloorDelimiter);
									const text = replaceText(CONST.Text.DownloadTemplate.Template, {
										'{Review ID}': review.rid,
										'{Review Title}': review.title,
										'{Review URL}': replaceText(CONST.URL.ReviewShow, {'{RID}': rid, '{Page}': 1}),
										'{Download Time}': new Date().toLocaleString(),
										'{Format}': format,
										'{Floor Content}': floorText,
									});
									const blob = new Blob([text], {
										type: 'text/plain',
										endings: 'native'
									});
									return blob;
								}

								function outputBBCode() {
									const floorText = review.floors.map(floordata => replaceText(CONST.Text.DownloadTemplate.FloorTemplate, {
										'{FloorNum}': floordata.floorNum,
										'{Username}': floordata.username,
										'{UserID}': floordata.userid,
										'{Time}': floordata.time,
										'{Floor Title}': floordata.title ? replaceText(CONST.Text.DownloadTemplate.FloorTitleTemplate, {'{Title}': floordata.title}) : '',
										'{Floor Content}': floordata.contentBBCode
									})).join(CONST.Text.DownloadTemplate.FloorDelimiter);
									const text = replaceText(CONST.Text.DownloadTemplate.Template, {
										'{Review ID}': review.rid,
										'{Review Title}': review.title,
										'{Review URL}': replaceText(CONST.URL.ReviewShow, {'{RID}': rid, '{Page}': 1}),
										'{Download Time}': new Date().toLocaleString(),
										'{Format}': format,
										'{Floor Content}': floorText,
									});
									const blob = new Blob([text], {
										type: 'text/plain',
										endings: 'native'
									});
									return blob;
								}

								function outputJson() {
									const blob = new Blob([JSON.stringify(review)], {
										type: 'text/plain',
										endings: 'transparent'
									});
									return blob;
								}
							}

							function downloadFailed(err) {
								alertify.error(CONST.Text.DownloadFailed);
								DoLog(LogLevel.Error, ['downloadFailed', err])
							}
						}
					},
					function pageChangeInPage() {
						// Clicking #pagelink>a
						$AEL(document, 'click', e => {
							const elm = e.target;
							if ([...$All('#pagelink>a')].includes(elm)) {
								e.preventDefault();
								refresh(getUrlArgv({
									url: elm.href,
									name: 'page',
									defaultValue: '1'
								}));
							}
						});

						// Input pagenum and hit enter
						// Deal <input> everytime <input> refreshed
						detectDom({
							selector: '#pagelink [name="page"]',
							callback: input => {
								input.type = 'number';
								input.style.width = '10ex'; // 6ex(maxlength=6) + 4ex(increase/decrease button)
								input.onkeydown = e => {};
								$AEL(input, 'keydown', e => {
									if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
										refresh(input.value, 10);
									}
								});
							},
							once: false
						});
					}
				];

				// Functions for every floor <table>
				// These will be executed for every floor <table> once, containing all in html and add via refreshing
				// These may be executed without floors inserted into current dom
				const floorFuncs = [
					function floorQuote(floor, doc) {
						// '本帖已经超过允许回复时间'
						if (!$('#pcontent')) {
							return false;
						}

						const floorA = $(floor, CONST.Selector.FloorA);
						const button = $$CrE({
							tagName: 'span',
							classes: [CommonStyle.ClassName.Button],
							listeners: [['click', quote]],
							props: {
								innerText: CONST.Text.Quote
							}
						});
						const tip_panel = $$CrE({
							tagName: 'div',
							props: {
								innerText: CONST.Text.QuoteNum[0]
							}
						});
						const btn = $$CrE({
							tagName: 'span',
							props: {
								innerText: CONST.Text.QuoteNum[1]
							},
							classes: [CommonStyle.ClassName.Button],
							listeners: [['click', quoteNum]]
						});
						tip_panel.appendChild(btn);
						const panel = tippy(button, {
							content: tip_panel,
							theme: 'wenku_tip',
							placement: 'top',
							interactive: true,
						});
						floorA.insertAdjacentElement('beforebegin', button);
						floorA.insertAdjacentHTML('beforebegin', ' | ');

						function quote() {
							const bbcode = `${getFloorLink(floor)} [quote]${getFloorContent($(floor, CONST.Selector.FloorContent))}[/quote]`;
							insert(bbcode);
						}

						function quoteNum() {
							const bbcode = getFloorLink(floor);
							insert(bbcode);
						}

						// Insert into <textarea id='pcontent'>
						function insert(bbcode, focus=true) {
							const textarea = $('#pcontent:not(#dialog #pcontent)');
							utils.insertText(textarea, bbcode, focus);
						}
					},
					function editInPage(floor, doc) {
						const editBtn = $(floor, `a[href^="https://${location.host}/modules/article/reviewedit.php?yid="]`);
						editBtn && editBtn.addEventListener('click', (e) => {
							e.preventDefault();

							utils.openDialog(e.target.href + '&ajax_gets=jieqi_contents');
						})
					},
					function scaleimgs(floor, doc) {
						const w = $('#content').clientWidth * 0.8 - 3 - 3; // td.width = "80%", .even {padding: 3px;}, table.grid {padding: 3px;}
						[...$All(floor, '.divimage>img')].forEach(img => {
							const loaded = img.width && img.height;
							const myOnload = e => {
								img.resized = img.width > w;
								img.width = img.resized ? w : img.width;
							}
							loaded ? myOnload() : img.onload = myOnload;
							img.onmouseover = e => img.resized && (img.style.cursor = 'pointer');
						});
					}
				];

				// Do not load functions when required by other modules and not running in reviewshow page
				if (utils.testChecker(FuncInfo.checker)) {
					detectDom('.main.m_foot', function() {
						loadBasicFuncs(basicFuncs);
						loadFloorFuncs(floorFuncs);
					});
				}
				return {refresh};

				// Refresh current page without reload
				// Argument: pageOrDom: page number (string or number) or a document
				function refresh(pageOrDom='last') {
					if (typeof pageOrDom === 'string' || typeof pageOrDom === 'number') {
						const page = pageOrDom;
						utils.getDocument(
							replaceText(CONST.URL.ReviewShow, {'{RID}': getUrlArgv('rid'), '{Page}': page.toString()}),
							applyDocument
						);
					} else {
						const dom = pageOrDom;
						applyDocument(dom);
					}

					function applyDocument(dom) {
						loadFloorFuncs(floorFuncs, dom);

						// Record scroll status
						const scrollStatus = [window.scrollY, $('#content').scrollTop];

						// Get floors' info
						const oldInfos = parseFloors(document).map(floor => info(floor));
						const newInfos = parseFloors(dom).map(floor => info(floor));

						// Get all floors' info that should stay in page and remove unreserved old floors from page
						// Fill newInfos directly first, then use oldInfos to replace same-floor-infos
						const reservedInfos = [...newInfos];
						oldInfos.forEach(old_info => {
							const sameNew = reservedInfos.findIndex(new_info => isSameFloor(new_info, old_info).same);
							const yidsameNewInfo = reservedInfos.find(new_info => isSameFloor(new_info, old_info).yid);
							if (sameNew >= 0) {
								reservedInfos.push(old_info);
								reservedInfos.splice(sameNew, 1);
							} else {
								old_info.floor.remove();
								if (yidsameNewInfo) {
									const titleRight = $(yidsameNewInfo.floor, CONST.Selector.FloorTitleRight);
									titleRight.insertAdjacentHTML('afterbegin', ' | ');
									titleRight.insertAdjacentElement('afterbegin', $$CrE({
										tagName: 'span',
										classes: [CommonStyle.ClassName.Text],
										props: {
											innerText: CONST.Text.FloorContentModified
										}
									}));
								}
							}
						});
						reservedInfos.sort((info1, info2) => info1.num_yid - info2.num_yid);

						// Insert new floors that do not currently exist in page and sort all floors in page
						reservedInfos.forEach(info => appendFloor(info.floor));

						// Update page url
						const page = $(dom, '#pagestats').innerText.match(/(\d+)\/\d+/)[1];
						const url = replaceText(CONST.URL.ReviewShow, {'{RID}': getUrlArgv('rid'), '{Page}': page.toString()})
						utils.setPageUrl(url);

						// Update #pagelink
						const newPagelink = $(dom, '#pagelink');
						const oldPagelink = $('#pagelink');
						const parent = oldPagelink.parentElement;
						// Remove old first, to aviod #pagelink dealers misgot the old one
						oldPagelink.remove();
						// Append new #pagelink
						parent.appendChild(newPagelink);

						// Recover scroll status
						[window.scrollY, $('#content').scrollTop] = scrollStatus;
					}

					// Get floor info, returns {floor, yid, str_yid, num_yid, title, content}
					function info(floor) {
						const yid = $(floor, CONST.Selector.FloorA).href.match(/#(yid\d+)/)[1];
						const str_yid = yid;
						const num_yid = parseInt(str_yid.replace('yid', ''), 10);
						const title = $(floor, CONST.Selector.FloorTitle).innerHTML;
						const content = getFloorContent($(floor, CONST.Selector.FloorContent));
						return {floor, yid, str_yid, num_yid, title, content};
					}

					// Compare whether two floors/infos are completely or partly same
					// Returns: {same, yid, title, content}
					function isSameFloor(ft1, ft2) {
						const getInfo = ft => ft.toString() === '[object HTMLTableElement]' ? info(ft) : ft;
						const [info1, info2] = [getInfo(ft1), getInfo(ft2)];
						const yid = info1.yid === info2.yid;
						const title = info1.title === info2.title;
						const content = info1.content === info2.content;
						const same = yid && title && content
						return {same, yid, title, content};
					}

					// Append a floor into the bottom of page
					function appendFloor(floor) {
						$(CONST.Selector.PagesTable).insertAdjacentElement('beforebegin', floor);
					}
				}

				// Get floor content by BBCode format (content only, no title)
				// Argv: <div> content element
				function getFloorContent(contentEle, original=false) {
					const subNodes = contentEle.childNodes;
					let content = '';

					for (const node of subNodes) {
						const type = node.nodeName;
						switch (type) {
							case '#text':
								// Prevent 'Quote:' repeat
								content += node.data.replace(/^\s*Quote:\s*$/, ' ');
								break;
							case 'IMG':
								// wenku8 has forbidden [img] tag for secure reason (preventing CSRF)
								//content += '[img]S[/img]'.replace('S', node.src);
								content += original ? '[img]S[/img]'.replace('S', node.src) : ' S '.replace('S', node.src);
								break;
							case 'A':
								content += '[url=U]T[/url]'.replace('U', node.getAttribute('href')).replace('T', getFloorContent(node));
								break;
							case 'BR':
								// no need to add \n, because \n will be preserved in #text nodes
								//content += '\n';
								break;
							case 'DIV':
								if (node.classList.contains('jieqiQuote')) {
									content += getTagedSubcontent('quote', node);
								} else if (node.classList.contains('jieqiCode')) {
									content += getTagedSubcontent('code', node);
								} else if (node.classList.contains('divimage')) {
									content += getFloorContent(node, original);
								} else {
									content += getFloorContent(node, original);
								}
								break;
							case 'CODE': content += getFloorContent(node, original); break; // Just ignore
							case 'PRE':  content += getFloorContent(node, original); break; // Just ignore
							case 'SPAN': content += getFontedSubcontent(node); break; // Size and color
							case 'P':    content += getFontedSubcontent(node); break; // Text Align
							case 'B':    content += getTagedSubcontent('b', node); break;
							case 'I':    content += getTagedSubcontent('i', node); break;
							case 'U':    content += getTagedSubcontent('u', node); break;
							case 'DEL':  content += getTagedSubcontent('d', node); break;
							default:     content += getFloorContent(node, original); break;
								/*
									case 'SPAN':
										subContent = getFloorContent(node);
										size = node.style.fontSize.match(/\d+/) ? node.style.fontSize.match(/\d+/)[0] : '';
										color = node.style.color.match(/rgb\((\d+), ?(\d+), ?(\d+)\)/);
										break;
									*/
						}
					}
					return content;

					function getTagedSubcontent(tag, node) {
						const subContent = getFloorContent(node, original);
						return '[{T}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{S}', subContent);
					}

					function getFontedSubcontent(node) {
						let tag, value;

						let strSize = node.style.fontSize.match(/\d+/);
						let strColor = node.style.color;
						let strAlign = node.align;
						strSize = strSize ? strSize[0] : null;
						strColor = strColor ? rgbToHex.apply(null, strColor.match(/\d+/g)) : null;

						tag = tag || (strSize  ? 'size'  : null);
						tag = tag || (strColor ? 'color' : null);
						tag = tag || (strAlign ? 'align' : null);
						value = value || strSize || null;
						value = value || strColor || null;
						value = value || strAlign || null;

						const subContent = getFloorContent(node, original);
						if (tag && value) {
							return '[{T}={V}]{S}[/{T}]'.replaceAll('{T}', tag).replaceAll('{V}', value).replaceAll('{S}', subContent);
						} else {
							return subContent;
						}

						function rgbToHex(r, g, b) {return ((r << 16) | (g << 8) | b).toString(16).padStart('0', 6);}
					}
				}

				// Get link bbcode points to given floor
				// Argv: <table> floor element
				function getFloorLink(floor) {
					const floorA = $(floor, 'a[href^="#yid"]');
					return `[url=${floorA.href}]${floorA.innerText}[/url]`;
				}

				// func() for each func
				function loadBasicFuncs(basicFuncs) {
					basicFuncs.forEach(func => loadWithWrapper(func));
				}

				// func(floor, doc) for each floor and floorFunc
				function loadFloorFuncs(floorFuncs, doc=document) {
					const floors = parseFloors(doc);
					floorFuncs.forEach(func => {
						floors.forEach(floor => loadWithWrapper(func, floor, doc));
					});
				}

				// Get all floor <table> as an array
				function parseFloors(doc=document) {
					const content = $(doc, '#content');
					const tables = $All(doc, CONST.Selector.Floor);
					return [...tables];
				}

				function loadWithWrapper(func, ...args) {
					setTimeout(function() {
						/* This will lose erroor stack information
						try {
							func.apply(null, args);
						} catch (err) {
							Err(err);
						}
						*/
						func.apply(null, args);
					}, 0);
				}
			}
		},

		// Reply area enhance
		{
			name: '编辑器增强',
			description: '发帖/回帖 输入框功能增强,提供本地图片快速插入等小功能',
			id: 'ReplyAreaEnhance',
			STOP: false,
			checker: {
				type: 'starturl',
				value: [
					// Page: reviews list
					`https://${location.host}/modules/article/reviews.php`,

					// Page: review
					`https://${location.host}/modules/article/reviewshow.php`,

					// Page: review edit
					`https://${location.host}/modules/article/reviewedit.php`,

					// Page: book
					`https://${location.host}/book`,
					`https://${location.host}/modules/article/articleinfo.php`,
				]
			},
			CONST: {
				Text: {
					InsertWebImage: '插入网图链接',
					SelectLocalImage: '选择本地图片',
					IWItip: '直接插入网络图片的链接地址',
					SLItip: '选择本地图片上传到第三方图床,然后再插入图床提供的图片链接</br>您也可以直接拖拽图片到输入框,或者Ctrl+V直接粘贴您剪贴板里面的图片</br></br>上传图片请遵守法律以及图床使用规定</br><span style="color: orange;">请<span style="color: red;">不要滥用</span>(你知道什么样算是滥用,包括但不限于上传违反法律/图床规定的图片、短时间大量上传图片等),如果因此出现问题,<span style="color: red;">开发者不对用户的这种恶意行为负责</span>,出现此种情况开发者只能选择<span style="color: red;">关闭此功能</span></span>',
					InputImageURL: '请输入图片路径,支持https以及包含中文和特殊符号的链接',
					DefaultInputURL: 'https://',
					ProtocolMissing: '请输入完整的图片路径!',
					ExtensionInvalid: '图片的链接需要以.jp(e)g/.png/.gif/.bmp等常见图片扩展名结尾',
					ImageOnly: '抱歉,您选择的图片格式无法识别</br>(建议选择jpeg/png格式的图片)',
					Uploading: '正在上传图片…',
					UploadSuccess: '图片上传成功',
					UploadError: '图片上传失败',
					ErrorMarkerID: '上传失败',
					SubmitBtnHotkey: '(Ctrl+Enter)',
					alertTitle: '书评吐槽',
					NoEmptySubmit: '请不要提交空的评论',
					ContentTooShort: '回复内容太短,至少需要7个字节</br>(通常来讲,一个汉字占两个字节,一个数字/字母/普通符号占一个字节)',
					ReplyFailed: '发送失败,请重试',
					DraftInited: '书评草稿初始化完毕',
					DraftLoaded: '成功加载了于{time}编辑的草稿',
					DraftSaved: '编辑框内容已于{time}保存到草稿',
					DraftCleared: '已清空草稿',
					AlertTitle: '编辑器增强',
					DraftSetting: '书评草稿',
					Expires: '草稿保存时间(天)',
					StorageSetting: {
						SettingTitle: '草稿管理',
						title: '标题',
						content: '内容',
						time: '上次编辑时间',
						Edit: '打开'
					}
				},
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						draftexpires: 31,
						drafts: []
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				},
			},
			func: function() {
				const WenkuBlockGUI = require('WenkuBlockGUI');
				const imager = require('imager');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				let ubbCode = '';

				detectDom({
					selector: 'form[name="frmreview"]',
					callback: form => enhance(form),
					once: false
				});
				return {enhance};

				// Enhance a UBBEditor
				// Arguments: form
				function enhance(form) {
					if (form.matches('#dialog form') && !$(form, '#UBB_Menu')) {
						// in dialog and no menu exist, load UBBEditor
						loadUBBEditor(UBBEditor => work(form, UBBEditor));
					} else {
						// Directly in page, wait for UBBEditor
						detectDom(form, '#UBB_Menu', menu => work(form, unsafeWindow.UBBEditor))
					}

					function work(form, UBBEditor) {
						const textarea = $(form, 'textarea[name="pcontent"]');
						const ptitle = $(form, 'input[name="ptitle"]') || makeptitle();

						imgSelector();
						extensiveEditor();
						editorDrafts();
						hotkeyReply();
						onsubmitEnhance();

						// Broadcast: a new UBBEditor has created (Not used yet)
						FL_postMessage('UBBEditorCreated', {form, UBBEditor});

						// Provide image inputs
						function imgSelector() {
							// Imager menu
							const menu = $(form, '#UBB_Menu');
							const elmImage = $(form, '#menuItemInsertImage');
							const imagers = new WenkuBlockGUI.PlusList({
								id: 'plus_imager',
								list: [
									{value: CONST.Text.InsertWebImage, tip: CONST.Text.IWItip, onclick: insertWebpic},
									{value: CONST.Text.SelectLocalImage, tip: CONST.Text.SLItip, onclick: pickfile}
								],
								parentElement: menu.parentElement,
								insertBefore: $(form, '#SmileListTable'),
								visible: false,
								onshow: onshow
							});
							elmImage.onclick = (e) => {
								e.stopPropagation();
								imagers.show();
							};
							$AEL(document, 'click', imagers.hide);

							// drag-drop & copy-paste
							textarea.addEventListener('paste', pictureGot);
							textarea.addEventListener('dragenter', destroyEvent);
							textarea.addEventListener('dragover', destroyEvent);
							textarea.addEventListener('drop', pictureGot);

							function onshow() {
								imagers.div.style.left = UBBEditor.GetPosition(elmImage).x + 'px';
								imagers.div.style.top = UBBEditor.GetPosition(elmImage).y + 20 + 'px';
							}

							function pickfile() {
								const fileinput = $CrE('input');
								fileinput.type = 'file';
								fileinput.addEventListener('change', pictureGot);
								fileinput.click();
							}

							function pictureGot(e) {
								// Get picture file
								const input = e.dataTransfer || e.clipboardData || window.clipboardData || e.target;
								if (!input.files || input.files.length === 0) {return false;};
								const file = input.files[0];
								const mimetype = file.type;
								const name = file.name;

								// Pasting an unrecognizable file is not a mistake
								// Maybe the user just wants to paste the filename here
								// Otherwise getting an unrecognizable file is a mistake
								if (!mimetype || mimetype.split('/')[0] !== 'image') {
									if (!e.clipboardData && !window.clipboardData) {
										destroyEvent(e);
										alertify.error(CONST.Text.ImageOnly);
									}
									return false;
								} else {
									destroyEvent(e);
								}

								// Insert picture marker
								const id = utils.randstr(16, true, textarea.value);
								const marker = replaceText('[image_uploading={ID} name={NAME}]', {
									'{ID}': id,
									'{NAME}': name
								});
								utils.insertText(textarea, marker);

								// Upload
								alertify.notify(CONST.Text.Uploading);
								imager.upload(file, function onload(url) {
									// Calculate selection position changes
									const [posStart, posEnd] = [textarea.selectionStart, textarea.selectionEnd].map(
										pos => calcPosition(pos, textarea.value.indexOf(marker), marker.length, url.length)
									);

									// Just replace but not replaceAll, because replaceAll make calculation selection position changes much more difficult
									textarea.value = textarea.value.replace(marker, url);

									// Apply selection position changes
									setTimeout(e => textarea.setSelectionRange(posStart, posEnd));

									// Show success
									alertify.success(CONST.Text.UploadSuccess);
								}, function onerror(err) {
									textarea.value = textarea.value.replace(marker, marker.replace(id, CONST.Text.ErrorMarkerID));
									alertify.error(CONST.Text.UploadError);
									DoLog(LogLevel.Error, ['Reply area enhance - pictureGot: Image upload error', err]);
								}, ['custom-extention']);

								// Calculate selection start/end with current position and text modification
								function calcPosition(curPos, textStart, oldLen, newLen) {
									const textEnd = textStart + oldLen;
									if (curPos < textStart) {
										// Selection position is not affected by text modification
										return curPos;
									} else if (curPos >= textStart && curPos <= textEnd) {
										// Selection position is somewhere inside old text - just set position to the end of new text
										return textStart + newLen;
									} else if (curPos > textEnd) {
										// Selection position is completly affected, move position with length modification
										return curPos + newLen - oldLen;
									}
									// NO WAY, CODE SHOULDNT BE HERE
									Err('calcPosition: Unexpected execution');
									return 0;
								}
							}

							function insertWebpic() {
								alertify.prompt(CONST.Text.alertTitle, CONST.Text.InputImageURL, CONST.Text.DefaultInputURL, function onok(e, val) {
									if (!/^https?:\/\//.test(val)) {
										alertify.alert(CONST.Text.alertTitle, CONST.Text.ProtocolMissing);
										return false;
									}
									if (!/\.(jpe?g|png|gif|bmp)$/.test(val)) {
										alertify.confirm(CONST.Text.alertTitle, CONST.Text.ExtensionInvalid);
										return false;
									}
									const url = new URL(val).href;
									utils.insertText(textarea, url);
								}, function oncancel() {});
							}
						}

						// Provide #ptitle if doesn't exist by default
						function makeptitle() {
							if (!$(form, 'input[name="ptitle"]')) {
								const html = '<tr>\n    <td class="odd" width="25%">标题</td>\n    <td class="even"><input type="text" class="text" name="ptitle" id="ptitle" size="60" maxlength="60" value=""></td>\n  </tr>'
								$(form, 'caption+tbody').insertAdjacentHTML('afterbegin', html);
							}
							return $(form, 'input[name="ptitle"]');
						}

						// Textarea auto expand (A HUGE MOUNTAIN OF SHIT CODE)
						// 你真的要点开看这里的代码吗?在这里放一双没有看过这代码的眼睛先(👀)
						// 如果想要了解其原理,可以参照 https://luszy.com/archives/383.html
						// 虽然但是,这段代码他能跑🏃🏻🏃🏻🏃🏻!!!
						function extensiveEditor() {
							if (![...$All('#dialog form')].includes(form)) {
								const cstyle = getComputedStyle(textarea);
								// Create elements
								const container = $$CrE({
									tagName: 'div',
									styles: {
										position: 'relative',
										'min-width': `${textarea.clientWidth}px`,
										'min-height': `${textarea.clientHeight}px`,
										'max-width': '650px',
										width: 'fit-content',
										height: 'fit-content',
										display: 'flow-root',
									}
								});
								const placeholder = $$CrE({
									tagName: 'div',
									styles: {
										visibility: 'hidden',
										'max-width': '650px',
										'max-height': '60vh',
										'overflow-y': 'scroll',
										...['white-space', 'word-wrap', 'font', 'padding', 'margin', 'border'].reduce((style, prop) => {
											style[prop] = cstyle[prop];
											return style;
										}, {}),
									}
								});
								// Adjust textarea's style
								textarea.style.width = `calc(100% - ${cstyle['border-left-width']} - ${cstyle['border-right-width']})`;
								textarea.style.height = `calc(100% - ${cstyle['border-top-width']} - ${cstyle['border-bottom-width']})`;
								textarea.style.top = textarea.style.left = '0';
								textarea.style.position = 'absolute';
								textarea.style.resize = 'none';
								textarea.style['overflow-y'] = 'scroll';
								textarea.style['max-width'] = '650px';
								textarea.style['max-height'] = '60vh';
								// Modify innerText while textarea.value changes
								const writeValue = e => {
									setTimeout(e => {
										placeholder.innerText = textarea.value;
										textarea.value.endsWith('\n') && (placeholder.innerHTML += ' ');
									}, 0);
								}
								$AEL(textarea, 'input', writeValue);
								$AEL(textarea.form, 'reset', writeValue);
								Object.defineProperty(textarea, 'value', {
									get: Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').get,
									set: function(val) {
										const rval = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.apply(this, arguments);
										writeValue();
										return rval;
									},
									enumerable: true,
									configurable: true
								})
								writeValue();
								// Modify DOM
								textarea.insertAdjacentElement('afterend', container);
								container.appendChild(textarea);
								container.appendChild(placeholder);
							}
						}

						// Drafting
						function editorDrafts() {
							// No drafts for in-dialog forms
							if (form.matches('#dialog form')) {
								return false;
							}

							// Draft status text
							const text = $$CrE({
								tagName: 'span',
								props: { innerText: CONST.Text.DraftInited },
								styles: { 'margin-left': '0.5em' }
							});
							const container = $(form, 'table:not(table table)>tbody>tr:last-of-type>td:last-of-type');
							[...container.childNodes].filter(node => node.nodeName === '#text').forEach(node => node.remove());
							container.appendChild(text);

							// form properties, for recognizing different forms
							const props = [...$All(form, 'input[type="hidden"]')].reduce((obj, input) => ((obj[input.name] = obj[input.value], obj)), {});
							props.formaction = form.getAttribute('action');
							const key = Object.keys(props).sort().map(prop => `${prop}=${props[prop]}`).join(',');

							// Save draft when change fires, or every 30 seconds passed
							[textarea, ptitle].forEach(elm => ['change', 'blur'].forEach(evt => $AEL(elm, evt, e => saveDraft())));
							setInterval(saveDraft, 30 * 1000);

							// Load previous draft from config
							loadDraft();

							function loadDraft() {
								const draft = CONFIG.drafts.find(d => d.key === key);
								if (draft) {
									ptitle.value = draft.title;
									textarea.value = draft.content;
									text.innerText = replaceText(CONST.Text.DraftLoaded, {
										'{time}': utils.getTime(draft.time)
									});
								}
							}

							function saveDraft() {
								const i = CONFIG.drafts.findIndex(d => d.key === key);
								if (!ptitle.value && !textarea.value) {
									if (i >= 0) {
										CONFIG.drafts.splice(i, 1);
										text.innerText = CONST.Text.DraftCleared;
									}
									return false;
								}

								const draft = i >= 0 ? CM.getConfig(`drafts/${i}`) : {key};
								draft.title = ptitle.value;
								draft.content = textarea.value;
								draft.time = new Date().getTime();
								draft.url = location.href;
								i >= 0 ? CM.setConfig(`drafts/${i}`, draft) : CONFIG.drafts.push(draft);

								text.innerText = replaceText(CONST.Text.DraftSaved, {
									'{time}': utils.getTime(draft.time)
								});
							}
						}

						// Reply with hotkey
						function hotkeyReply() {
							const submitBtn = $(form, '[type="submit"]');
							submitBtn.value = submitBtn.value.trim() + ' ' + CONST.Text.SubmitBtnHotkey;
							submitBtn.style.padding = '0.4em 0.8em';
							submitBtn.style.height = '100%';
							$AEL(form, 'keydown', e => {
								if (e.key === 'Enter' && (utils.getOS() !== 'Windows' ? (e.metaKey || e.ctrlKey) : e.ctrlKey)) {
									submitBtn.click();
								}
							});
						}

						// onsubmit enhance
						function onsubmitEnhance() {
							$AEL(form, 'submit', function(e) {
								// Do not submit empty form
								if (!textarea.value) {
									alertify.alert(CONST.Text.alertTitle, CONST.Text.NoEmptySubmit);
									e.preventDefault();
									return false;
								}

								// Content length check (≥7)
								if (utils.formEncode(textarea.value).replaceAll(/%[0-9ABCDEF]{2}/g, '%').length < 7) {
									alertify.alert(CONST.Text.alertTitle, CONST.Text.ContentTooShort);
									e.preventDefault();
									return false;
								}

								// Reply without page reload while in reviewshow page
								if (utils.testChecker(FL_getFunction('ReviewEnhance').checker)) {
									e.preventDefault();
									utils.submitForm(form, function onload(response) {
										FL_postMessage('ReplySent', {response, form, UBBEditor});
									}, function onerror(err) {
										FL_postMessage('ReplyFailed', {err, form, UBBEditor});
										DoLog(LogLevel.Error, ['Send review reply err', err]);
										alertify.alert(CONST.Text.alertTitle, CONST.Text.ReplyFailed);
									});
								}
							});
						}
					}

					// Loadin UBBEditor with callback(UBBEditor)
					function loadUBBEditor(callback, retry=3) {
						const textarea = $(form, '#pcontent');
						const id = textarea.id = 'pcontent-' + utils.randstr(4); //id shouldn't contain '"' and '\'

						// Cache ubbCode
						if (!ubbCode) {
							GM_xmlhttpRequest({
								method: 'GET',
								url: `https://${location.host}/scripts/ubbeditor_gbk.js`,
								responseType: 'blob',
								onload: response => {
									const blob = response.response;
									const reader = new FileReader();
									$AEL(reader, 'load', e => {
										ubbCode = reader.result;
										loadin();
									});
									$AEL(reader, 'error', err => {
										Err('ReplyAreaEnhance: loadUBBEditor reader error');
									});
									reader.readAsText(blob, ['gbk', 'big5'][utils.getLang()]);
								},
								onerror: e => --retry ? loadUBBEditor(retry) : Err('ReplyAreaEnhance: loadUBBEditor xhr error')
							});
						} else {
							loadin();
						}

						function loadin() {
							const hideeve = 'function hideeve(){form.querySelector("#"+ubb_subdiv).style.display = "none";}'
							const code = `${ubbCode};\n${hideeve}\nUBBEditor.Create('${id}'); cb(UBBEditor);`;
							const func = Function('form', 'cb', code);
							setTimeout(e => func(form, callback), 0);
						}
					}
				}
			},
			alwaysRun: function() {
				const utils = require('utils');
				const CONST = FuncInfo.CONST;

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				CM.updateAllConfigs();

				const time = new Date().getTime();
				const expires = t => (time - t) >= CONFIG.draftexpires * 24 * 60 * 60 * 1000;
				const drafts = CM.getConfig('drafts').filter(d => !expires(d.time));
				CM.setConfig('drafts', drafts);
			},
			setting: function setter() {
				const SettingPanel = require('SettingPanel');
				const CONST = FuncInfo.CONST;

				const CM = new ConfigManager(CONST.Config_Ruleset);
				CM.updateAllConfigs();

				const panel = new SettingPanel.easySettings({
					title: CONST.Text.AlertTitle,
					areas: [{
						title: CONST.Text.DraftSetting,
						items: [{
							text: CONST.Text.Expires,
							path: 'draftexpires',
							type: 'number'
						}],
					}]
				}, CM);

				new SettingPanel.easyStorage({
					title: CONST.Text.StorageSetting.SettingTitle,
					path: 'drafts',
					key: 'key',
					panel,
					props: {
						title: {
							type: 'string',
							name: CONST.Text.StorageSetting.title
						},
						content: {
							type: 'string',
							name: CONST.Text.StorageSetting.content
						},
						time: {
							type: 'time',
							name: CONST.Text.StorageSetting.time
						}
					},
					operations: [{
						type: 'func',
						text: CONST.Text.StorageSetting.Edit,
						func: (e, items, index) => {
							window.open(items[index].url);
							return items;
						}
					}, {type: 'delete'}]
				}, CM);
			}
		},

		// Index block folding
		{
			name: '首页版块折叠',
			description: '为首页所有版块提供具有记忆性的折叠/展开',
			id: 'IndexFolding',
			STOP: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/index\.php([\?#][\s\S]*)?$/
				]
			},
			CONST: {
				Text: {
					Fold: '折叠',
					Unfold: '展开',
					ResetTitle: `${GM_info.script.name}-首页版块折叠 功能提示`,
					ResetTip: `由于文库首页新增了以往没有的版块,现在首页各个板块的折叠/展开状态需要您重新设置。`,
				},
				CSS: '.block.fold>.blockcontent {display: none;}.block>.blocktitle>.plus-fold {float: right;padding-right: 7px;cursor: pointer;}',
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						fold: {}
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				},
			},
			func: function() {
				const utils = require('utils');
				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				addStyle(CONST.CSS, 'plus-indexfolding');

				// Deal each blocks once they loaded
				detectDom({
					// Detect blocktitle so when we call dealBlock we have blocktitle loaded
					selector: '.block>.blocktitle',
					callback: blocktitle => dealBlock(blocktitle.parentElement),
					once: false
				})

				function dealBlock(block) {
					if ($(block, '.blocktitle>.plus-fold')) {
						return false;
					}

					// 'fold' class on .block
					const id = $(block, '.blocktitle').innerText;
					const blockfold = CONFIG.fold[id] || (CONFIG.fold[id] = false);

					// recorded: true - fold, false - not fold; unrecorded: undefined - not fold
					// repeating classList.add would not cause multiple same class on one elm, removing class that doesn't exist occurs no error
					block.classList[blockfold ? 'add' : 'remove']('fold');

					// Folding button
					const blocktitle = $(block, '.blocktitle');
					const button = $$CrE({
						tagName: 'span',
						props: {
							innerText: blockfold ? CONST.Text.Unfold : CONST.Text.Fold
						},
						classes: ['plus-fold'],
						styles: {
							'line-height': `${blocktitle.clientHeight}px`
						},
						listeners: [['click', e => {
							const newfold = !block.classList.contains('fold');
							block.classList[newfold ? 'add' : 'remove']('fold');
							button.innerText = newfold ? CONST.Text.Unfold : CONST.Text.Fold;
							CONFIG.fold[id] = newfold;
						}]]
					});
					$(block, '.blocktitle').appendChild(button);
				}
			}
		},

		// Review collection
		{
			name: '书评收藏',
			description: '收藏特定书评在文库首页展示',
			id: 'ReviewCollection',
			STOP: false,
			checker: {
				type: 'regurl',
				value: [
					/^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/,
					/^https?:\/\/www\.wenku8\.(net|cc)\/index\.php([\?#][\s\S]*)?$/,
				]
			},
			CONST: {
				Text: {
					ReviewCollection: '书评收藏',
					StarButton: '加入/取消收藏此书评',
					AlertTitle: '书评收藏'
				},
				URL: {
					ReviewShow: `https://${location.host}/modules/article/reviewshow.php?rid={rid}`
				},
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						reviews: [{
							rid: '228884',
							title: '主题:文库导航姬'
						}],
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				},
			},
			func: function() {
				const WenkuBlockGUI = require('WenkuBlockGUI');
				const SPanel = require('SidePanel');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;

				const functions = [{
					checker: {
						type: 'regurl',
						value: /^https?:\/\/www\.wenku8\.(net|cc)\/modules\/article\/reviewshow\.php\/?/
					},
					func: returnValue => {
						const starBtn = SPanel.insert({
							index: 3,
							faicon: `${CONFIG.reviews.some(r => r.rid === getUrlArgv('rid')) ? 'fa-solid' : 'fa-regular'} fa-star`,
							tip: CONST.Text.StarButton,
							onclick: e => {
								const rid = getUrlArgv('rid');
								const star = CONFIG.reviews.findIndex(r => r.rid === rid);
								starBtn.faicon.className = `${star >= 0 ? 'fa-regular' : 'fa-solid'} fa-star`;
								star >= 0 ? CONFIG.reviews.splice(star, 1) : CONFIG.reviews.push({
									rid, title: $('#content>table th>strong:not(table table strong)').innerText
								});
							}
						});
					}
				}, {
					checker: {
						type: 'regurl',
						value: /^https?:\/\/www\.wenku8\.(net|cc)\/index\.php([\?#][\s\S]*)?$/,
					},
					detectDom: '.main.m_foot',
					func: returnValue => {
						const block = WenkuBlockGUI.makeIndexRightToplistBlock({
							title: CONST.Text.ReviewCollection,
							links: CONFIG.reviews.map(r => ({
								url: replaceText(CONST.URL.ReviewShow, {'{rid}': r.rid}),
								text: r.title
							}))
						});
						$('#left').appendChild(block.block);

						// Drag-drop to move books
						const sortable = new Sortable.default(block.blockcontent.children[0], {
							draggable: 'li'
						});
						sortable.on('sortable:sorted', function(e) {
							const div = e.dragEvent.data.originalSource;
							const reviews = moveItem(CM.getConfig('reviews'), e.oldIndex, e.newIndex);
							CONFIG.reviews = reviews;
							/* Wrong coding:
							The following code CANNOT WORK! Because CONFIG.books[index] saves the path that
							contains index, and always gives the current value of this path in GM_storage.
							As we know, moveItem removes arr[from] and then inserts it to arr[to], and
							as moveItem inserts(using arr.splice) CONFIG.books[oldIndex] into newIndex,
							the proxy for CONFIG.books[oldIndex]'s path is currently pointing to
							the new value at CONFIG.books[oldIndex], which is the next book of the
							dragging one! Tempermonkey will look into its(the next book's) props, and save
							them into disk, at CONFIG.books[newIndex], which should be storing the dragging
							book's properties.
							To avoid this from happenning, we get the pure object using CM.getConfig, sort it
							using moveItem, and then save it to GM_storage manually, just like code above.

							The wrong code is written below:
							moveItem(CONFIG.reviews, e.oldIndex, e.newIndex);
							*/
						});
					}
				}];

				function moveItem(arr, from, to) {
					const item = arr.splice(from, 1)[0];
					arr.splice(to, 0, item);
					return arr;
				}

				return utils.loadFuncs(functions);
			}
		},

		// Account switching
		{
			name: '账号快捷切换',
			description: '顶栏快速切换账号',
			id: 'AccountSwitcher',
			STOP: false,
			global: true,
			checker: {
				type: 'switch',
				value: true
			},
			CONST: {
				Text: {
					Heading: '切换账号:',
					Empty: '未添加任何账号',
					NotLoggedin: '未登录',
					AddNewAccount: '[+] 添加账号',
					SaveAccount: `保存账号到 ${GM_info.script.name}`,
					LoginErrorTitle: '登录错误',
					LoginError: '登录错误,请检查您的网络后重试',
					AlertTitle: '账号快捷切换',
					SaveAccountWhenLogin: '登录时自动保存账号',
					AccountSetting: {
						SettingTitle: '账号管理',
						Username: '账号',
						Password: '密码(已加密,点击查看)',
					}
				},
				Data: {
					Login: '&username={username}&password={password}&usecookie={usecookie}&action=login'
				},
				URL: {
					LoginPage: `https://${location.host}/login.php?ajax_gets=jieqi_contents`,
					Login: `https://${location.host}/login.php?do=submit`
				},
				Config_Ruleset: {
					'version-key': 'config-version',
					'ignores': ["LOCAL-CDN"],
					'defaultValues': {
						saveAccounts: true,
						secret: '', // secret to encrypt & decrypt account passwords
						accounts: [/*{
							username: '用户名 明文',
							password: '密码 密文'
						}*/]
					},
					'updaters': {
						/*'config-key': [
							function() {
								// This function contains updater for config['config-key'] from v0 to v1
							},
							function() {
								// This function contains updater for config['config-key'] from v1 to v2
							}
						]*/
					}
				},
			},
			func: function() {
				const utils = require('utils');
				const CONST = FuncInfo.CONST;
				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				detectDom('.main.m_top .fr', e => work());
				if (!CONFIG.secret) {CONFIG.secret = utils.randstr()}

				function work() {
					accountSwitcher();
					accountSaver();
				}

				function accountSwitcher() {
					const curUsername = getCurUsername();
					const ACCADDKEY = `AddAccount_${utils.randstr()}`;
					const fl = $('.main.m_top .fl');
					const select = $$CrE({
						tagName: 'select',
						listeners: [['change', e => {
							if (select.value === ACCADDKEY) {
								newAccount();
								Array.from(select.children).find(opt => opt.value === curUsername).selected = true;
							} else if (select.value) {
								switchTo(select.value);
							}
						}]]
					});
					if (CONFIG.accounts.length > 0) {
						CONFIG.accounts.forEach(acc => select.appendChild($$CrE({
							tagName: 'option',
							props: {
								value: acc.username,
								innerText: acc.username,
								selected: acc.username === curUsername,
							}
						})));
						if (!curUsername) {
							select.insertAdjacentElement('afterbegin', $$CrE({
								tagName: 'option',
								props: {
									innerText: CONST.Text.NotLoggedin,
									selected: true
								}
							}));
						}
					} else {
						select.appendChild($$CrE({
							tagName: 'option',
							props: {
								innerText: CONST.Text.Empty
							}
						}));
					}
					select.appendChild($$CrE({
						tagName: 'option',
						props: {
							value: ACCADDKEY,
							innerText: CONST.Text.AddNewAccount
						}
					}));
					fl.insertAdjacentText('beforeend', CONST.Text.Heading);
					fl.appendChild(select);
				}

				// Record new account
				function newAccount() {
					utils.openDialog(CONST.URL.LoginPage);
				}

				// Switch to an exist account
				function switchTo(username) {
					const password = utils.decrypt(CONFIG.accounts.find(acc => acc.username === username).password, CONFIG.secret);

					GM_xmlhttpRequest({
						method: 'POST',
						url: CONST.URL.Login,
						headers: { "Content-Type": "application/x-www-form-urlencoded" },
						data: replaceText(CONST.Data.Login, {
							'{username}': utils.formEncode(username),
							'{password}': utils.formEncode(password),
							'{usecookie}': utils.formEncode('315360000'), // '有效期:保存一年'
						}),
						responseType: 'blob',
						onload: response => {
							utils.parseDocument(response.response, onAccountSwitched, response);
						},
						onerror: err => alertify.error(CONST.Text.LoginError),
						ontimeout: err => alertify.error(CONST.Text.LoginError)
					});
				}

				// Save account for every login form
				function accountSaver() {
					detectDom('form[name="frmlogin"]', form => {
						// Whether-to-save checkbox
						const checkbox = $$CrE({
							tagName: 'input',
							props: {
								type: 'checkbox',
								checked: CONFIG.saveAccounts
							},
							classes: ['plus-saveaccount'],
							styles: { 'vertical-align': 'middle' },
							listeners: [['change', e => {
								CONFIG.saveAccounts = checkbox.checked;
							}]]
						});
						const span = $$CrE({
							tagName: 'span',
							props: { innerText: CONST.Text.SaveAccount },
							styles: { 'vertical-align': 'middle' }
						});
						const label = $$CrE({
							tagName: 'label',
							props: { innerText: CONST.Text.SaveAccount },
							styles: { 'text-align': 'center' }
						});
						const tr = $$CrE({
							styles: {
								'text-align': 'center',
							}
						});
						label.insertAdjacentElement('afterbegin', checkbox);
						tr.appendChild(label);
						$(form, 'tbody').appendChild(tr);

						// Submit with ajax
						$AEL(form, 'submit', e => {
							e.preventDefault();

							utils.submitForm(form, function onload(response) {
								const blob = response.response;
								utils.parseDocument(blob, onAccountSwitched, response);
							}, function onerror(err) {
								alertify.error(CONST.Text.LoginError);
								Err(err);
							});
						});
					});
				}

				function onAccountSwitched(dom, response) {
					const redirector = $(dom.head, 'meta[http-equiv="refresh"]');
					if (dom.body.childElementCount === 1 && !redirector) {
						// Error page
						[...$All(dom, 'a[href^="javascript:"]')].forEach(button => {
							button.href = 'javascript: void 0;';
							$AEL(button, 'click', e => altbox.close());
						});
						const altbox = alertify.alert(CONST.Text.LoginErrorTitle, $(dom, '.block'));
					} else if (dom.body.childElementCount === 1 && redirector) {
						// Login successed
						// Set cookie for xbrowser
						if (window.mbrowser) {
							response.responseHeaders.split('\n').filter(str => str).map(str => str.split(/\s*:\s*/, 2)).filter(arr => arr[0].toLowerCase() === 'set-cookie').forEach(arr => document.cookie = arr[1]);
						}

						// Save account
						const form = $('form[name="frmlogin"]');
						if (form && $(form, '.plus-saveaccount').checked) {
							const username = $(form, 'input[name="username"]').value;
							const password = utils.encrypt($(form, 'input[name="password"]').value, CONFIG.secret);

							CONFIG.accounts.every(acc => acc.username !== username) && CONFIG.accounts.push({ username, password });
						}

						if ($('#dialog')) {
							// Login in dialog
							unsafeWindow.closeDialog();
							utils.refreshPage();
						} else {
							// Login in page or switcher
							const url = redirector.content.match(/url=([^;]+)/)[1];
							location.href = url;
						}
					} else {
						// Returned something that isn't an api page
						// Shouldn't be here
					}
				}

				function getCurUsername() {
					const match = $URL.decode(document.cookie).match(/[;,]\s*jieqiUserName=([0-9a-zA-Z]+)[;,]/);
					const curUsername = match ? match[1] : null;
					return curUsername;
				}
			},
			setting: function setter() {
				const SettingPanel = require('SettingPanel');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;

				const CM = new ConfigManager(CONST.Config_Ruleset);
				const CONFIG = CM.Config;
				CM.updateAllConfigs();

				const panel = new SettingPanel.easySettings({
					title: CONST.Text.AlertTitle,
					items: [{
						text: CONST.Text.SaveAccountWhenLogin,
						path: 'saveAccounts',
						type: 'boolean'
					}]
				}, CM);

				SettingPanel.easyStorage({
					title: CONST.Text.AccountSetting.SettingTitle,
					path: 'accounts',
					key: 'username',
					panel,
					props: {
						username: {
							type: 'string',
							name: CONST.Text.AccountSetting.Username
						},
						password: {
							type: 'string',
							name: CONST.Text.AccountSetting.Password,
							styles: { cursor: 'default' },
							listeners: [['click', function(username, e) {
								if (!e.isTrusted) { return false; }
								const password = utils.decrypt(CONFIG.accounts.find(acc => acc.username === username).password, CONFIG.secret);
								this.innerText = password;
							}]],
							oncreate: function(username, block) {
								block.element.innerText = '***';
							}
						}
					},
					operations: [{type: 'delete'}]
				}, CM);
			}
		},

		// Old Config Importer
		{
			name: '旧版配置导入',
			description: '从1.x.x.x版本的旧版脚本自动导入旧配置',
			id: 'Old-Config-Importer',
			STOP: true,
			checker: {
				type: '',
				value: []
			},
			CONST: {},
			func: function() {},
			setting: function() {}
		},

		// Toy box
		{
			name: '小功能集合',
			description: '一些小功能,如未登录时www.wenku8.net自动跳转到www.wenku8.net/index.php等等',
			id: 'toybox',
			system: true,
			STOP: false,
			checker: {
				type: 'switch',
				value: true
			},
			CONST: {
				Text: {
					RunningTip: `${GM_info.script.name} 正在运行,版本 ${GM_info.script.version}`
				}
			},
			func: function() {
				const CommonStyle = require('CommonStyle');
				const utils = require('utils');
				const CONST = FuncInfo.CONST;

				const functions = [
					// https://www.wenku8.net/ 跳转到 https://www.wenku8.net/index.php
					// 通常来说网页会自动跳转,但是如果不登录就不会,所以帮未登录用户跳一下
					{
						toyid: 'IndexJump',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\/?$/
						},
						func: e => location.href = `https://${location.host}/index.php`
					},

					// 没有www.开头时,跳转到www.开头的同网址
					// 无www.开头的证书对不上,www.开头的证书正常,跳转过来降安全风险,同时为其他FunctionModule提供便利
					{
						toyid: 'wwwJump',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/wenku8\.(net|cc)\/?/
						},
						func: e => location.href = location.href.replace(/^https?:\/\/wenku8\.(net|cc)(\/?)/, 'https://www.wenku8.$1$2')
					},

					// http页面跳转到https(这种情况通常在旧版浏览器中出现)
					{
						toyid: 'httpsJump',
						checker: {
							type: 'regurl',
							value: /^http:\/\/www\.wenku8\.(net|cc)\/?/
						},
						func: e => location.href = location.href.replace(/^http:\/\//, 'https://')
					},

					// 首页显示一个正在运行的文字提示
					{
						toyid: 'runningText',
						checker: {
							type: 'regurl',
							value: /^https?:\/\/www\.wenku8\.(net|cc)\/index\.php([\?#][\s\S]*)?$/
						},
						detectDom: '#right',
						func: e => {
							const board = $('#centers>.block:first-of-type>.blockcontent');
							board.appendChild($CrE('br'));
							board.appendChild($$CrE({
								tagName: 'span',
								props: {
									innerText: CONST.Text.RunningTip
								},
								classes: [CommonStyle.ClassName.Text]
							}));
						}
					},

					// 兼容 haoa 的 轻小说文库下载
					{
						toyid: 'wenku8HaoaCompat',
						detectDom: 'body',
						func: e => {
							// Our script requires user's browser to be the latest, at least String.prototype.replaceAll exists
							if (String.prototype.replaceAll.toString() !== 'function replaceAll() { [native code] }') {
								const ifr = $$CrE({
									tagName: 'iframe',
									props: { srcdoc: '<html></html>' },
									styles: {
										border: '0',
										padding: '0',
										width: '0',
										height: '0',
										position: 'fixed',
										'pointer-events': 'none'
									}
								});
								document.body.appendChild(ifr);
								String.prototype.replaceAll = ifr.contentWindow.String.prototype.replaceAll;
							} else {
								const replaceAll = String.prototype.replaceAll;
								Object.defineProperty(String.prototype, 'replaceAll', {
									get: e => replaceAll,
									set: e => true
								});
							}
						}
					}
				];

				return utils.loadFuncs(functions);;
			}
		},

		// Function Module Model
		/*
		{
			name: 'XXXX',
			description: 'Xxxx',
			id: 'XxXx',
			STOP: true,
			checker: {
				type: '',
				value: []
			},
			CONST: {},
			func: function() {},
			setting: function() {}
		},
		*/
	];

	main();
	function main() {
		GMXHRHook(5);

		const FuncLoader = new FunctionLoader(Functions);
		FuncLoader.GlobalProvides = {
			GM_xmlhttpRequest, GM_getResourceText,
			LogLevel, DoLog, Err,
			$, $All, $CrE, $AEL, $$CrE, addStyle, detectDom, destroyEvent,
			copyProp, copyProps, parseArgs, escJsStr, replaceText,
			getUrlArgv, dl_browser, dl_GM,
			AsyncManager,
			ConfigManager, $URL,
			GreasyFork,
			tippy, alertify, Sortable, vkbeautify, Darkmode, CryptoJS
		};
		FuncLoader.loadAll();
	}

	function FunctionLoader(functions) {
		// function DoLog() {}
		// Arguments: level=LogLevel.Info, logContent, logger='log'
		const [LogLevel, DoLog] = (function() {
			const LogLevel = {
				None: 0,
				Error: 1,
				Success: 2,
				Warning: 3,
				Info: 4,
			};

			return [LogLevel, DoLog];
			function DoLog() {
				// Get window
				const win = (typeof(unsafeWindow) === 'object' && unsafeWindow !== null) ? unsafeWindow : window;

				const LogLevelMap = {};
				LogLevelMap[LogLevel.None] = {
					prefix: '',
					color: 'color:#ffffff'
				}
				LogLevelMap[LogLevel.Error] = {
					prefix: '[Error]',
					color: 'color:#ff0000'
				}
				LogLevelMap[LogLevel.Success] = {
					prefix: '[Success]',
					color: 'color:#00aa00'
				}
				LogLevelMap[LogLevel.Warning] = {
					prefix: '[Warning]',
					color: 'color:#ffa500'
				}
				LogLevelMap[LogLevel.Info] = {
					prefix: '[Info]',
					color: 'color:#888888'
				}
				LogLevelMap[LogLevel.Elements] = {
					prefix: '[Elements]',
					color: 'color:#000000'
				}

				// Current log level
				DoLog.logLevel = (win.isPY_DNG && win.userscriptDebugging) ? LogLevel.Info : LogLevel.Warning; // Info Warning Success Error

				// Log counter
				DoLog.logCount === undefined && (DoLog.logCount = 0);

				// Get args
				let [level, logContent, logger] = parseArgs([...arguments], [
					[2],
					[1,2],
					[1,2,3]
				], [LogLevel.Info, 'DoLog initialized.', 'log']);

				// Write storage log
				systemLog(isWenkuFunction(this) ? this.id : null, Object.keys(LogLevel)[Object.values(LogLevel).indexOf(level)], logContent, logger);

				// Log to console
				let msg = '%c' + LogLevelMap[level].prefix + (typeof GM_info === 'object' ? `[${GM_info.script.name}]` : '') + (isWenkuFunction(this) ? `[${this.id}]` : '') + (LogLevelMap[level].prefix ? ' ' : '');
				let subst = LogLevelMap[level].color;

				switch (typeof(logContent)) {
					case 'string':
						msg += '%s';
						break;
					case 'number':
						msg += '%d';
						break;
					default:
						msg += '%o';
						break;
				}

				// Log to console when log level permits
				if (level <= DoLog.logLevel) {
					if (++DoLog.logCount > 512) {
						console.clear();
						DoLog.logCount = 0;
					}
					console[logger](msg, subst, logContent);
				}
			}
		}) ();

		const FL = this;
		FL.Messager = new Messager();
		FL.DefaultProvides = {window, unsafeWindow, GM_info, require, isWenkuFunction, Messager: FL.Messager};
		FL.CodeProvides = {ConfigManager};
		FL.GrantFuncs = {
			FL_listFunctions(id) {
				return functions.map(objFunc => objFunc.id);
			},
			FL_getFunction(id) {
				const objFunc = functions.find(objFunc => objFunc.id === id);
				return objFunc ? objFunc : null;
			},
			FL_disableFunction(id) {
				const objFunc = functions.find(objFunc => objFunc.id === id);
				objFunc && CM.setConfig(`funcs/${id}/enabled`, false);
			},
			FL_enableFunction(id) {
				const objFunc = functions.find(objFunc => objFunc.id === id);
				objFunc && CM.setConfig(`funcs/${id}/enabled`, true);
			},
			FL_loadSetting(id) {
				const objFunc = functions.find(objFunc => objFunc.id === id);
				loadSetting(objFunc);
			},
			FL_getDebug() {
				return CM.getConfig('debug');
			},
			FL_exportConfig(filename='export.json') {
				const url = URL.createObjectURL(new Blob([ JSON.stringify({
					type: 'config',
					time: new Date().getTime(),
					data: CM.getConfig('funcs')
				}) ], { type: 'application/json' }));
				dl_browser(url, filename);
				setTimeout(() => URL.revokeObjectURL(url));
			},
			FL_importConfig(callback) {
				$$CrE({
					tagName: 'input',
					props: { type: 'file' },
					listeners: [['change', e => {
						const file = e.target.files[0];
						const reader = new FileReader();
						reader.onload = e => {
							let json;
							try {
								json = JSON.parse(reader.result);
							} catch(err) {
								callback(1); // File is not valid json
								return;
							}
							if (json?.type !== 'config' || !json.data) {
								callback(2); // Json is not valid config export
							}
							CM.setConfig('funcs', json.data);
							callback(0);
						};
						reader.readAsText(file);
					}]]
				}).click();
			},
			FL_postMessage(name, data=null, target=null) {
				const id = isWenkuFunction(this) ? this.id : null;
				FL.Messager.postMessage(name, data, id, target);
			},
			FL_recieveMessage(name, func, source=null, read_history=false) {
				const id = isWenkuFunction(this) ? this.id : null;
				FL.Messager.recieveMessage(name, func, source, id, read_history);
			}
		};
		FL.BindProvides = {DoLog, ...FL.GrantFuncs};
		FL.GlobalProvides = {};
		FL.returnValues = {};
		FL.load = load;
		FL.loadSetting = loadSetting;
		FL.executeAs = executeAs;
		FL.loadAll = loadAll;

		const CM = new ConfigManager(CONST.Config_Ruleset);
		const CONFIG = CM.Config;

		initStorage();
		initFunctions();
		initDebug();

		// Prepare storage structure
		function initStorage() {
			for (const objFunc of functions) {
				const userStorageKey = getUserStorageKey();
				// Function config
				!CONFIG.funcs.hasOwnProperty(objFunc.id) && (CONFIG.funcs[objFunc.id] = {
					enabled: true,
					storage: {},
				});

				// User config
				!objFunc.global && !CONFIG.funcs[objFunc.id].storage.hasOwnProperty(userStorageKey) && (CONFIG.funcs[objFunc.id].storage[userStorageKey] = {});
			}
		}

		// Pre-deal all objFunc
		/* set Symbol.toStringTag to 'Wenku8+_Function'
		   add storage object getter and setter
		*/
		function initFunctions() {
			for (const objFunc of functions) {
				deal(objFunc);
			}

			function deal(objFunc) {
				objFunc[Symbol.toStringTag] = 'Wenku8+_Function';
				for (const prop of Object.keys(CONFIG.funcs[objFunc.id])) {
					reflectProperty(CONFIG.funcs[objFunc.id], objFunc, prop);
				}
			}
		}

		// Prepare debug storage
		function initDebug() {
			const iframe = unsafeWindow !== unsafeWindow.top;
			const parent = iframe ? unsafeWindow.top.performance.timeOrigin : null;
			CONFIG.debug.push({
				timeOrigin: performance.timeOrigin,
				iframe, parent,
				console: []
			});

			// fill tab info when dom content loaded
			document.readyState === 'complete' ? saveTab() : $AEL(document, 'readystatechange', saveTab);
			function saveTab() {
				if (document.readyState === 'complete') {
					const DebugStore = CONFIG.debug.find(d => d.timeOrigin === performance.timeOrigin);
					if (DebugStore) {
						DebugStore.title = document.title;
						DebugStore.url = location.href;
					}
				}
			}

			// Catch window errors
			$AEL(unsafeWindow, 'error', function(e) {
				DoLog(LogLevel.Error, 'Global error catched');
				systemLog('window.onerror', 'Error', e.error, 'thrown error');
			});
		}

		function isWenkuFunction(obj) {
			return typeof obj === 'object' && obj !== null && obj.toString() === '[object Wenku8+_Function]';
		}

		// Load an objFunc
		function load(objFunc) {
			objFunc.STOP && Err('Cannot load an objFunc whose STOP sign is true');

			// Check if already loaded
			if (FL.returnValues.hasOwnProperty(objFunc.id)) {
				return FL.returnValues[objFunc.id];
			}

			// Call wrapper and save return value
			const return_value = executeAs(objFunc.func, objFunc);
			FL.returnValues[objFunc.id] = return_value;
			return return_value;
		}

		// Check whether an objFunc should be loaded automatically
		function check(objFunc) {
			if (!objFunc.enabled) {return false;}
			if (objFunc.STOP) {return false;}
			const checker = objFunc.checker;
			if (!checker) {return true;}
			const values = Array.isArray(checker.value) ? checker.value : [checker.value]
			return values.some(value => {
				switch (checker.type) {
					case 'regurl': {
						return !!location.href.match(value);
					}
					case 'func': {
						try {
							return value();
						} catch (err) {
							DoLog(LogLevel.Error, CONST.Text.Loader.CheckerError);
							DoLog(LogLevel.Error, err);
							return false;
						}
					}
					case 'switch': {
						return value;
					}
					case 'starturl': {
						return location.href.startsWith(value);
					}
					case 'startpath': {
						return location.pathname.startsWidth(value);
					}
					default: {
						DoLog(LogLevel.Error, CONST.Text.Loader.CheckerInvalid);
						return false;
					}
				}
			});
		}

		function loadSetting(objFunc) {
			typeof objFunc.setting === 'function' && executeAs(objFunc.setting, objFunc);
		}

		// Execute func on behalf of objFunc
		function executeAs(func, objFunc) {
			// Make wrapper
			const wrapperCode = wrapFuncCode(func);
			const storage = CM.makeSubStorage(`funcs/${objFunc.id}/storage/${objFunc.global ? '' : getUserStorageKey()}`);
			const BindProvides = bindProvides();
			const provides = {...FL.DefaultProvides, ...FL.GlobalProvides, ...BindProvides, ...storage, FuncInfo: objFunc};
			const wrapper = Function.apply(null, Object.keys(provides).concat(wrapperCode));

			// Execute
			return wrapper.apply(null, Object.values(provides));

			function wrapFuncCode(func) {
				let wrapperCode = `
					try {
						return (${func.toString()})();
					} catch (err) {
						DoLog(LogLevel.Error, \`Function ${escJsStr(objFunc.id)} error\`);
						if (unsafeWindow.isPY_DNG && unsafeWindow.userscriptDebugging) {
							// Thrown error contains error stacks in eval scope
							throw err;
						} else {
							// console.error only contains error stacks in outer(loader) scope
							DoLog(LogLevel.Error, err, 'error');
						}
					}
				`;
				for (const [pname, pfunc] of Object.entries(FL.CodeProvides)) {
					wrapperCode = `var ${pname} = ${pfunc.toString()};\n` + wrapperCode;
				}
				return wrapperCode;
			}

			function bindProvides() {
				const BindProvides = {...FL.BindProvides};
				for (const [name, func] of Object.entries(BindProvides)) {
					BindProvides[name] = func.bind(objFunc);
				}
				return BindProvides;
			}
		}

		function require(id) {
			const objFunc = functions.find(objFunc => objFunc.id === id);
			objFunc.STOP && Err('Required objFunc\'s STOP sign shouldn\'t be true');
			return load(objFunc);
		}

		// Log to storage
		function systemLog(id, level, content, logger) {
			const timestamp = (new Date()).getTime();
			content = convert(content);

			const DebugStore = CONFIG.debug.find(d => d.timeOrigin === performance.timeOrigin);
			if (DebugStore) {
				DebugStore.console.push({id, level, content, logger, timestamp});
				DebugStore.console.length > 100 && DebugStore.console.splice(0, DebugStore.console.length - 100);
				DebugStore.spliced = true;
			}

			if (CONFIG.debug.length > 10) {
				CONFIG.debug.splice(0, CONFIG.debug.length - 10);
			}

			// convert content into storage-acceptable value
			function convert(content) {
				// Errors
				if (content instanceof Error) {
					const obj = {};
					copyProps(content, obj, ['name', 'message', 'stack']);
					return obj;
				}

				// Special values
				const specialVals = [null, undefined, NaN, Infinity];
				const specialNames = ['null', 'undefined', 'NaN', 'Infinity'];
				if (specialVals.includes(content)) {
					return specialNames[specialVals.indexOf(content)];
				}

				// Other
				return content;
			}
		}

		// Check and load all objFuncs that should be loaded
		function loadAll() {
			for (const objFunc of functions) {
				if (!objFunc.STOP) {
					// Execute objFunc.alwaysRun if exist.
					/* The name "alwaysRun" is given by AI. See these pics if u'r interested (Chinese conversation)
					https://p.sda1.dev/12/b991a6513bce3ae95180d6a5503f3694/截屏2023-07-26 下午5.16.53.jpg
					https://p.sda1.dev/12/cad9eaa7b265e21367a76c028fd6208c/截屏2023-07-26 下午5.17.08.jpg
					*/
					typeof objFunc.alwaysRun === 'function' && executeAs(objFunc.alwaysRun, objFunc);

					// Execute objFunc.func if checker passed
					check(objFunc) && load(objFunc);
				}
			}
		}

		function getUserStorageKey() {
			return getUserID() || 'Guest';
		}

		function getUserID() {
			const match = $URL.decode(document.cookie).match(/jieqiUserId=(\d+)/);
			const id = match && match[1] ? Math.floor(match[1]) : null;
			return isNaN(id) ? null : id;
		}

		function defineGetter(obj, prop, getter) {
			defineProperty(obj, prop, getter, v => true);
		}

		function reflectProperty(from, to, prop) {
			defineProperty(to, prop, () => from[prop], v => {from[prop] = v});
		}

		function defineProperty(obj, prop, getter, setter) {
			Object.defineProperty(obj, prop, {
				get: getter,
				set: setter,
				configurable: false,
				enumerable: true,
			});
		}

		function Messager() {
			const M = this;
			const listeners = Object.create(null);
			const history = {};

			M.recieveMessage = recieveMessage;
			M.postMessage = postMessage;

			function recieveMessage(name, func, poster_id=null, listener_id=null, read_history=false) {
				// Register listener
				const listener = {func, poster_id, listener_id};
				if (name in listeners) {
					listeners[name].push(listener);
				} else {
					listeners[name] = [listener];
				}

				// Read history messages
				if (read_history && history[name]) {
					const messages = history[name];
					for (const message of messages) {
						const correctPoster = !poster_id || message.poster_id === poster_id;
						const correctListener = !message.listener_id || message.listener_id === listener_id;
						correctPoster && correctListener && setTimeout(e => listener.func(message.data), 0);
					}
				}
			}

			function postMessage(name, data=null, poster_id=null, listener_id=null) {
				// Post to recievers
				if (name in listeners) {
					for (const listener of listeners[name]) {
						const correctPoster = !listener.poster_id || listener.poster_id === poster_id;
						const correctListener = !listener_id || listener.listener_id === listener_id;
						correctPoster && correctListener && listener.func(data);
					}
				}

				// Save in history messages
				!history[name] && (history[name] = []);
				history[name].push({name, data, poster_id, listener_id});
			}
		}
	}
})();