您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Sleazy Fork is available in English.
下载Kemono的内容为ZIP压缩文档
当前为
// ==UserScript== // @name Kemono zip download // @name:zh-CN Kemono 下载为ZIP文件 // @namespace https://greasyfork.org/users/667968-pyudng // @version 0.13.1 // @description Download kemono post in a zip file // @description:zh-CN 下载Kemono的内容为ZIP压缩文档 // @author PY-DNG // @license MIT // @match http*://*.kemono.su/* // @match http*://*.kemono.party/* // @require data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B // @require https://update.greasyfork.org/scripts/456034/1558509/Basic%20Functions%20%28For%20userscripts%29.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js // @resource vue-js https://unpkg.com/[email protected]/dist/vue.global.prod.js // @resource quasar-icon https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons // @resource quasar-css https://cdn.jsdelivr.net/npm/[email protected]/dist/quasar.prod.css // @resource quasar-js https://cdn.jsdelivr.net/npm/[email protected]/dist/quasar.umd.prod.js // @connect kemono.su // @connect kemono.party // @icon https://kemono.su/favicon.ico // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_addElement // @grant GM_getResourceText // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @run-at document-start // ==/UserScript== /* eslint-disable no-multi-spaces */ /* eslint-disable no-return-assign */ /* global LogLevel DoLog Err Assert $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded */ /* global setImmediate JSZip ItemSelector Vue Quasar */ (function __MAIN__() { 'use strict'; const CONST = { TextAllLang: { DEFAULT: 'en-US', 'zh-CN': { DownloadZip: '下载为ZIP文件', DownloadPost: '下载当前作品为ZIP文件', DownloadCreator: '下载当前创作者为ZIP文件', DownloadCustom: '自定义下载', Downloading: '正在下载...', FetchingCreatorPosts: '正在获取创作者作品列表...', SelectAll: '全选', TotalProgress: '总进度', ProgressBoardTitle: '下载进度', Settings: '设置', SaveContent: '保存文字内容到html文件', SaveApijson: '保存api结果到json文件', NoPrefix: '保存文件时不添加文件名前缀', FlattenFiles: '保存主文件和附件到同一级文件夹', FlattenFilesCaption: '主文件一般是封面图', CustomDownload: { PopupTitle: '自定义下载', TypeLabel: '下载...', ServiceLabel: '发布平台', CreatorLabel: '创作者ID', PostLabel: '内容ID', ExtFilterLabel: '只下载以下扩展名的文件', ExtFilterTooltip: '留空以下载所有文件', NoPrefixLabel: '保存文件时不添加文件名前缀', FlattenFilesLabel: '保存主文件和附件到同一级文件夹', FlattenFilesTooltip: '主文件一般是封面图', Type: { Post: '单篇内容', Creator: '创作者的多篇内容' }, Cancel: '取消', Download: '开始下载', }, PostsSelector: { Title: '选择内容', OK: '确定', Cancel: '取消', SelectAll: '全选', }, }, 'en-US': { DownloadZip: 'Download as ZIP file', DownloadPost: 'Download post as ZIP file', DownloadCreator: 'Download creator as ZIP file', DownloadCustom: 'Custom download', Downloading: 'Downloading...', FetchingCreatorPosts: 'Fetching creator posts list...', SelectAll: 'Select All', TotalProgress: 'Total Progress', ProgressBoardTitle: 'Download Progress', Settings: 'Settings', SaveContent: 'Save text content', SaveApijson: 'Save api result', NoPrefix: 'Do not add filename prefix', FlattenFiles: 'Save main file and attachments to same folder', FlattenFilesCaption: '"Main file" is usually the cover image', CustomDownload: { PopupTitle: 'Custom download', TypeLabel: 'Download ...', ServiceLabel: 'Service Name', CreatorLabel: 'Creator ID', PostLabel: '内容', ExtFilterLabel: 'Download files with these extensions only', ExtFilterTooltip: 'Leave blank to download all files', NoPrefixLabel: 'Do not add filename prefix', FlattenFilesLabel: 'Save main file and attachments to same folder', FlattenFilesTooltip: '"Main file" is usually the cover image', Type: { Post: 'single post', Creator: 'creator posts' }, Cancel: 'Cancel', Download: 'Download', }, PostsSelector: { Title: 'Select posts', OK: 'OK', Cancel: 'Cancel', SelectAll: 'Select All', }, } } }; // Init language const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT; CONST.Text = CONST.TextAllLang[i18n]; loadFuncs([{ id: 'utils', async func() { const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; function fillNumber(number, length) { const str = number.toString(); return '0'.repeat(length - str.length) + str; } /** * Async task progress manager \ * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \ * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)` */ class ProgressManager { /** @type {*} */ info; /** @type {number} */ steps; /** @type {progressCallback} */ callback; /** @type {number} */ finished; /** @type {ProgressManager[]} */ #children; /** @type {ProgressManager} */ #parent; /** * This callback is called each time a promise resolves * @callback progressCallback * @param {number} resolved_count * @param {number} total_count * @param {ProgressManager} manager */ /** * @param {number} [steps=0] - total steps count of the task * @param {progressCallback} [callback] - callback each time progress updates * @param {*} [info] - attach any data about this manager if need */ constructor(steps=0, callback=function() {}, info=undefined) { this.steps = steps; this.callback = callback; this.info = info; this.finished = 0; this.#children = []; this.#callCallback(); } add() { this.steps++; } /** * @template {Promise | null} task * @param {task} [promise] - task to await, null is acceptable if no task to await * @param {number} [finished] - set this.finished to this value, defaults to this.finished+1 * @returns {Awaited<task>} */ async progress(promise, finished) { const val = await Promise.resolve(promise); try { this.finished = typeof finished === 'number' ? finished : this.finished + 1; this.#callCallback(); //this.finished === this.steps && this.#parent && this.#parent.progress(); } finally { return val; } } /** * Creates a new ProgressManager as a sub-progress of this * @param {number} [steps=0] - total steps count of the task * @param {progressCallback} [callback] - callback each time progress updates, defaulting to parent.callback * @param {*} [info] - attach any data about the sub-manager if need */ sub(steps, callback, info) { const manager = new ProgressManager(steps ?? 0, callback ?? this.callback, info); manager.#parent = this; this.#children.push(manager); return manager; } #callCallback() { this.callback(this.finished, this.steps, this); } get children() { return [...this.#children]; } get parent() { return this.#parent; } /** * Resolves after all promise resolved, and callback each time one of them resolves * @param {Array<Promise>} promises * @param {progressCallback} callback */ static async all(promises, callback) { const manager = new ProgressManager(promises.length, callback); await Promise.all(promises.map(promise => manager.progress(promise, callback))); } } const PerformanceManager = (function() { class RunRecord { static #id = 0; /** @typedef {'initialized' | 'running' | 'finished'} run_status */ /** @type {number} */ id; /** @type {number} */ start; /** @type {number} */ end; /** @type {number} */ duration; /** @type {run_status} */ status; /** * Anything for programmers to mark and read, uses as a description for this run * @type {*} */ info; /** * @param {*} [info] - Anything for programmers to mark and read, uses as a description for this run */ constructor(info) { this.id = RunRecord.#id++; this.status = 'initialized'; this.info = info; } run() { const time = performance.now(); this.start = time; this.status = 'running'; return this; } stop() { const time = performance.now(); this.end = time; this.duration = this.end - this.start; this.status = 'finished'; return this; } } class Task { /** @typedef {number | string | symbol} task_id */ /** @type {task_id} */ id; /** @type {RunRecord[]} */ runs; /** * @param {task_id} id */ constructor(id) { this.id = id; this.runs = []; } run(info) { const record = new RunRecord(info); record.run(); this.runs.push(record); return record; } get time() { return this.runs.reduce((time, record) => { if (record.status === 'finished') { time += record.duration; } return time; }, 0) } } class PerformanceManager { /** @type {Task[]} */ tasks; constructor() { this.tasks = []; } /** * @param {task_id} id * @returns {Task | null} */ getTask(id) { return this.tasks.find(task => task.id === id); } /** * Creates a new task * @param {task_id} id * @returns {Task} */ newTask(id) { Assert(!this.getTask(id), `given task id ${escJsStr(id)} is already in use`, TypeError); const task = new Task(id); this.tasks.push(task); return task; } /** * Runs a task * @param {task_id} id * @param {*} run_info - Anything for programmers to mark and read, uses as a description for this run * @returns {RunRecord} */ run(task_id, run_info) { const task = this.getTask(task_id); Assert(task, `task of id ${escJsStr(task_id)} not found`, TypeError); return task.run(run_info); } totalTime(id) { if (id) { return this.getTask(id).time; } else { return this.tasks.reduce((timetable, task) => { timetable[task.id] = task.time; return timetable; }, {}); } } meanTime(id) { if (id) { const task = this.getTask(id); return task.time / task.runs.length; } else { return this.tasks.reduce((timetable, task) => { timetable[task.id] = task.time / task.runs.length; return timetable; }, {}); } } } return PerformanceManager; }) (); return { window: win, fillNumber, ProgressManager, PerformanceManager } } }, { id: 'dependencies', desc: 'load dependencies like vue into the page', detectDom: ['head', 'body'], async func() { const deps = [{ name: 'vue-js', type: 'script', }, { name: 'quasar-icon', type: 'style' }, { name: 'quasar-css', type: 'style' }, { name: 'quasar-js', type: 'script' }]; await Promise.all(deps.map(dep => { return new Promise((resolve, reject) => { switch (dep.type) { case 'script': { // Once load, dispatch load event on messager const evt_name = `load:${dep.name};${Date.now()}`; const rand = Math.random().toString(); const messager = new EventTarget(); const load_code = [ '\n;', `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`, `delete window[${escJsStr(rand)}];\n` ].join('\n'); unsafeWindow[rand] = messager; $AEL(messager, evt_name, resolve); GM_addElement(document.head, 'script', { textContent: GM_getResourceText(dep.name) + load_code, }); break; } case 'style': { GM_addElement(document.head, 'style', { textContent: GM_getResourceText(dep.name), }); resolve(); break; } } }); })); Quasar.setCssVar('primary', 'orange'); setTimeout(() => Quasar.Dark.set(true)); } }, { id: 'api', desc: 'api for kemono', async func() { let api_key = null; const Posts = { /** * Get a list of creator posts * @param {string} service - The service where the post is located * @param {number | string} creator_id - The ID of the creator * @param {string} [q] - Search query * @param {number} [o] - Result offset, stepping of 50 is enforced */ posts(service, creator_id, q, o) { const search = {}; q && (search.q = q); o && (search.o = o); return callApi({ endpoint: `/${service}/user/${creator_id}`, search }); }, /** * Get a specific post * @param {string} service * @param {number | string} creator_id * @param {number | string} post_id * @returns {Promise<Object>} */ post(service, creator_id, post_id) { return callApi({ endpoint: `/${service}/user/${creator_id}/post/${post_id}` }); } }; const Creators = { /** * Get a creator * @param {string} service - The service where the creator is located * @param {number | string} creator_id - The ID of the creator * @returns */ profile(service, creator_id) { return callApi({ endpoint: `/${service}/user/${creator_id}/profile` }); } }; const Custom = { /** * Get a list of creator's ALL posts, calling Post.posts for multiple times and joins the results * @param {string} service - The service where the post is located * @param {number | string} creator_id - The ID of the creator * @param {string} [q] - Search query */ async all_posts(service, creator_id, q) { const posts = []; let offset = 0; let api_result = null; while (!api_result || api_result.length === 50) { api_result = await Posts.posts(service, creator_id, q, offset); posts.push(...api_result); offset += 50; } return posts; } }; const API = { get key() { return api_key; }, set key(val) { api_key = val; }, Posts, Creators, Custom, callApi }; return API; /** * callApi detail object * @typedef {Object} api_detail * @property {string} endpoint - api endpoint * @property {Object} [search] - search params * @property {string} [method='GET'] * @property {boolean | string} [auth=false] - whether to use api-key in this request; true for send, false for not, and string for sending a specific key (instead of pre-configured `api_key`); defaults to false */ /** * Do basic kemono api request * This is the queued version of _callApi * @param {api_detail} detail * @returns */ function callApi(...args) { return queueTask(() => _callApi(...args), 'callApi'); } /** * Do basic kemono api request * @param {api_detail} detail * @returns */ function _callApi(detail) { const search_string = new URLSearchParams(detail.search).toString(); const url = `https://kemono.su/api/v1/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : ''); const method = detail.method ?? 'GET'; const auth = detail.auth ?? false; return new Promise((resolve, reject) => { auth && api_key === null && reject('api key not found'); const options = { method, url, headers: { accept: 'application/json' }, onload(e) { try { e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText); } catch(err) { reject(err); } }, onerror: err => reject(err) } if (typeof auth === 'string') { options.headers.Cookie = auth; } else if (auth === true) { options.headers.Cookie = api_key; } GM_xmlhttpRequest(options); }); } } }, { id: 'gui', desc: 'reusable GUI components', dependencies: 'dependencies', async func() { class ProgressBoard { /** @type {Vue} */ app; /** Vue component instance */ instance; /** @typedef {{ finished: number, total: number }} progress */ /** @typedef {{ name: string, progress: progress }} item */ /** @type {item[]} */ items; constructor() { const that = this; this.items = []; // GUI const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } }); const board = $$CrE({ tagName: 'div', classes: 'board' }); board.innerHTML = ` <q-layout view="hhh lpr fff"> <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :position="$q.platform.is.mobile ? 'standard' : 'bottom'" seamless :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile"> <q-card class="container-card"> <q-card-section class="header row"> <div class="text-h6">${ CONST.Text.ProgressBoardTitle }</div> <q-space></q-space> <q-btn :icon="minimized ? 'expand_less' : 'expand_more'" flat round dense @click="minimized = !minimized"></q-btn> <q-btn icon="close" flat round dense v-close-popup></q-btn> </q-card-section> <q-slide-transition> <q-card-section class="body" v-show="!minimized"> <q-list class="list" :class="{ minimized: minimized }"> <q-item tag="div" v-for="item of items"> <q-item-section> <q-item-label>{{ item.name }}</q-item-label> </q-item-section> <q-item-section> <q-linear-progress animation-speed="500" :indeterminate="item.progress.total === 0" :value="item.progress.total > 0 ? item.progress.finished / item.progress.total : 0" :color="item.progress.total > 0 && item.progress.total === item.progress.finished ? 'green' : 'blue'"></q-linear-progress> </q-item-section> </q-item> </q-list> </q-card-section> </q-slide-transition> </q-card> </q-dialog> </q-layout> `; container.append(board); detectDom('html').then(html => html.append(container)); detectDom('head').then(head => addStyle(` :is(.container-card):not(.mobile *) { max-width: 45vw; } .container-card :is(.header, .body):not(.mobile *) { width: 40vw; } .container-card :is(.body .list):not(.mobile *) { height: 40vh; overflow-y: auto; } .container-card :is(.body .list.minimized):not(.mobile *) { overflow-y: hidden; } `, 'progress-board-style')); this.app = Vue.createApp({ data() { return { items: that.items, minimized: false, show: false } }, methods: { debug() { debugger; } }, mounted() { that.instance = this; } }); this.app.use(Quasar); this.app.mount(board); } /** * update item's progress display on progress_board * @param {string} name - must be unique among all items * @param {progress} progress */ update(name, progress) { let item = this.instance.items.find(item => item.name === name); if (!item) { item = { name, progress }; this.instance.items.push(item); } item.progress = progress; } /** * remove an item * @param {string} name */ remove(name) { let item_index = this.instance.items.findIndex(item => item.name === name); if (item_index === -1) { return null; } return this.instance.items.splice(item_index, 1)[0]; } /** * remove all existing items */ clear() { this.instance.items.splice(0, this.instance.items.length); } get minimized() { return this.instance.minimized; } set minimized(val) { this.instance.minimized = !!val; } get closed() { return !this.instance.show; } set closed(val) { this.instance.show = !val; } } const board = new ProgressBoard(); class CustomDownloadPanel { app; instance; #promise; // Promise to be created on show() call, and resolved on user submit #resolve; // resolve function for the promise above constructor() { const that = this; // GUI const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } }); const panel = $$CrE({ tagName: 'div', classes: 'panel' }); panel.innerHTML = ` <q-layout view="hhh lpr fff"> <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile" @hide="cancel"> <q-card class="container-card-custom-dl"> <q-card-section class="header row"> <div class="text-h6">${ CONST.Text.CustomDownload.PopupTitle }</div> <q-space></q-space> <q-btn icon="close" flat round dense v-close-popup></q-btn> </q-card-section> <q-card-section class="body q-pa-md scroll"> <q-list> <q-item> <q-item-section> <q-select hide-bottom-space filled v-model="download_type" :options="avail_dl_types" label="${ CONST.Text.CustomDownload.TypeLabel }" ref="dltype_ref" :rules="[ val => !!val ]" lazy-rules></q-select> </q-item-section> </q-item> <q-item> <q-item-section> <q-select hide-bottom-space filled v-model="service" :options="avail_services" label="${ CONST.Text.CustomDownload.ServiceLabel }" ref="service_ref" :rules="[ val => !!val ]" lazy-rules></q-select> </q-item-section> </q-item> <q-item> <q-item-section> <q-input hide-bottom-space filled type="number" v-model.number="creator_id" label="${ CONST.Text.CustomDownload.CreatorLabel }" ref="creator_ref" :rules="[ val => !!val ]" lazy-rules></q-input> </q-item-section> </q-item> <q-item v-if="download_type.value === 'post'"> <q-item-section> <q-input hide-bottom-space filled type="number" v-model.number="post_id" label="${ CONST.Text.CustomDownload.PostLabel }" ref="post_ref" :rules="[ val => download_type.value !== 'post' || !!val ]" lazy-rules></q-input> </q-item-section> </q-item> <q-item> <q-item-section> <q-input hide-bottom-space filled v-model="ext_filter" label-slot> <template v-slot:label> <div class="row items-center all-pointer-events"> ${ CONST.Text.CustomDownload.ExtFilterLabel } <q-tooltip class="bg-grey-8" anchor="top left" self="bottom left" :offset="[0, 8]"> ${ CONST.Text.CustomDownload.ExtFilterTooltip } </q-tooltip> </div> </template> </q-input> </q-item-section> </q-item> <q-item tag="label" clickable> <q-item-section> <q-item-label>${ CONST.Text.CustomDownload.NoPrefixLabel }</q-item-label> </q-item-section> <q-item-section side top> <q-checkbox left-label v-model="no_prefix"></q-checkbox> </q-item-section> </q-item> <q-item tag="label" clickable> <q-item-section> <q-item-label>${ CONST.Text.CustomDownload.FlattenFilesLabel }</q-item-label> <q-item-label caption>${ CONST.Text.CustomDownload.FlattenFilesTooltip }</q-item-label> </q-item-section> <q-item-section side top> <q-checkbox left-label v-model="flatten_files"></q-checkbox> </q-item-section> </q-item> </q-list> </q-card-section> <q-card-actions :align="$q.platform.is.mobile ? 'center' : 'right'"> <q-btn flat v-close-popup>${ CONST.Text.CustomDownload.Cancel }</q-btn> <q-btn flat @click="submit" :loading="loading" color="primary">${ CONST.Text.CustomDownload.Download }</q-btn> </q-card-action> </q-card> </q-dialog> </q-layout> `; container.append(panel); detectDom('html').then(html => html.append(container)); detectDom('head').then(head => addStyle(` .container-card-custom-dl :is([type=text], input[type=password], input[type=number]) { box-shadow: unset; background: unset; } .container-card-custom-dl :is(.header, .body):not(.mobile *) { width: 30vw; min-width: 175px; } .container-card-custom-dl .body { max-height: 75vh; } `)); this.app = Vue.createApp({ data() { return { panel: that, show: false, autoclose: true, download_type: null, service: '', creator_id: 0, post_id: 0, ext_filter: '', no_prefix: false, flatten_files: false, loading: false, avail_dl_types: [{ label: CONST.Text.CustomDownload.Type.Post, value: 'post' }, { label: CONST.Text.CustomDownload.Type.Creator, value: 'creator' }], avail_services: [{ "label": "Patreon", "value": "patreon" }, { "label": "Pixiv Fanbox", "value": "fanbox" }, { "label": "Discord", "value": "discord" }, { "label": "Fantia", "value": "fantia" }, { "label": "Afdian", "value": "afdian" }, { "label": "Boosty", "value": "boosty" }, { "label": "Gumroad", "value": "gumroad" }, { "label": "SubscribeStar", "value": "subscribestar" }, { "label": "DLsite", "value": "dlsite" }] } }, methods: { submit() { if (!this.validate()) { return; } this.show = !this.autoclose; this.resolve({ download_type: this.download_type.value, service: this.service.value, creator_id: this.creator_id, post_id: this.post_id, ext_filter: this.ext_filter.split(/[, ]+/).map(ext => ext.replace(/^\.+/, '')).filter(ext => ext.length), no_prefix: this.no_prefix, flatten_files: this.flatten_files, }); }, cancel() { // This will also be invoked after download button clicked // Because the dialog popup will be closed after download button clicked // Since only the first call to the resolve function takes effect to the promise // This does not make anything wrong this.resolve(null); }, validate() { const refs = ['dltype_ref', 'service_ref', 'creator_ref', 'post_ref'].map(ref_name => this.$refs[ref_name]).filter(ref => !!ref); refs.forEach(ref => ref.validate()); return !refs.some(ref => ref.hasError); } }, mounted() { that.instance = this; } }); this.app.use(Quasar); this.app.mount(panel); } get loading() { return this.instance.loading; } set loading(val) { this.instance.loading = !!val; } get showing() { return this.instance.show; } /** @typedef {'post' | 'creator'} download_type */ /** @typedef {'patreon' | 'fanbox' | 'discord' | 'fantia' | 'afdian' | 'boosty' | 'gumroad' | 'subscribestar' | 'dlsite'} kemono_service */ /** * Display and wait for user submit * @param {Object} defaultValue * @param {download_type} defaultValue.download_type * @param {kemono_service} defaultValue.service * @param {number | string} defaultValue.creator_id * @param {number | string} [defaultValue.post_id] * @returns {Promise<null | { download_type: download_type, service: kemono_service, creator_id: number, post_id?: number, autoclose: boolean }>} */ show({ download_type = 'post', no_prefix = false, flatten_files = false, service = 'patreon', creator_id = null, post_id = null, autoclose = true } = {}) { if (this.showing) { return this.#promise; } ({ promise: this.#promise, resolve: this.#resolve } = Promise.withResolvers()); this.instance.resolve = this.#resolve; this.instance.download_type = this.instance.avail_dl_types.find(obj => obj.value === download_type); this.instance.service = this.instance.avail_services.find(obj => obj.value === service); this.instance.creator_id = parseInt(creator_id, 10); this.instance.post_id = parseInt(post_id, 10); this.instance.autoclose = autoclose; this.instance.no_prefix = no_prefix; this.instance.flatten_files = flatten_files; this.instance.show = true; return this.#promise; } close() { this.instance.show = false; } } const panel = new CustomDownloadPanel(); class PostsSelector { app; instance; posts; #promise; // Promise to be created on show() call, and resolved on user submit #resolve; // resolve function for the promise above /** * @typedef {Object} post * @property {number} id * @property {number} user * @property {string} service * @property {string} title * @property {string} content * @property {Object} embed * @property {boolean} shared_file * @property {string} added * @property {string} published * @property {string} edited * @property {Object} file * @property {Object} attachments * @property {*} poll * @property {*} captions * @property {*} tags */ constructor() { const that = this; // GUI const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } }); const panel = $$CrE({ tagName: 'div', classes: 'panel' }); panel.innerHTML = ` <q-layout view="hhh lpr fff"> <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile" @hide="cancel"> <q-card class="container-card-posts-selector"> <q-card-section class="header row"> <div class="text-h6">${ CONST.Text.PostsSelector.Title }</div> <q-space></q-space> <q-btn icon="close" flat round dense v-close-popup></q-btn> </q-card-section> <q-card-section class="body q-pa-md scroll"> <q-list separator> <q-item tag="label" clickable v-ripple> <q-item-section> <q-item-label lines="1"> <div class="text-subtitle1">${ CONST.Text.PostsSelector.SelectAll }</div> </q-item-label> </q-item-section> <q-item-section side> <q-checkbox v-model="select_all" @update:model-value="val => selectAll(val)"></q-checkbox> </q-item-section> </q-item> <q-item v-for="post of posts" tag="label" clickable v-ripple> <q-item-section avatar> <q-avatar square> <q-img loading="lazy" ratio="1" fit="cover" :src="post.file.path" v-if="!!post.file?.path"></q-img> </q-avatar> </q-item-section> <q-item-section> <q-item-label lines="1">{{ post.title }}</q-item-label> </q-item-section> <q-item-section side> <q-checkbox v-model="selected_posts" :val="post" @update:model-value="onSelect"></q-checkbox> </q-item-section> </q-item> </q-list> </q-card-section> <q-card-actions :align="$q.platform.is.mobile ? 'center' : 'right'"> <q-btn flat v-close-popup>${ CONST.Text.PostsSelector.Cancel }</q-btn> <q-btn flat @click="submit" color="primary">${ CONST.Text.PostsSelector.OK }</q-btn> </q-card-action> </q-card> </q-dialog> </q-layout> `; container.append(panel); detectDom('html').then(html => html.append(container)); detectDom('head').then(head => addStyle(` .container-card-posts-selector :is(.header, .body):not(.mobile *) { min-width: 175px; } .container-card-posts-selector .body { max-height: 75vh; } `)); this.app = Vue.createApp({ data() { return { selector: that, show: false, select_all: false, /** @type {post[]} */ posts: [], /** @type {post[]} */ selected_posts: [], } }, methods: { submit() { if (!this.validate()) { return; } this.show = false; this.resolve(this.selected_posts.map( post => this.selector.posts.find(p => p.id === post.id) )); }, cancel() { // This will also be invoked after submit button clicked // Because the dialog popup will be closed after download button clicked // Since only the first call to the resolve function takes effect to the promise // This does not make anything wrong this.selected_posts.splice(0, this.selected_posts.length); this.resolve(null); }, validate() { const refs = [].map(ref_name => this.$refs[ref_name]).filter(ref => !!ref); refs.forEach(ref => ref.validate()); return !refs.some(ref => ref.hasError); }, selectAll(selected) { if (selected ? this.selected_posts.length === this.posts.length : !this.selected_posts.length) { return; } this.selected_posts.splice(0, this.selected_posts.length); selected && this.selected_posts.push(...this.posts); selected && !this.select_all && (this.select_all = true); }, onSelect() { // Sort selected_posts by posts this.selected_posts.sort((p1, p2) => this.posts.indexOf(p2) - this.posts.indexOf(p1)); // Update select_all status const select_all = this.selected_posts.length === this.posts.length ? true : (this.selected_posts.length ? null : false); select_all !== this.select_all && (this.select_all = select_all); } }, mounted() { that.instance = this; } }); this.app.use(Quasar); this.app.mount(panel); } /** * * @param {Object} detail * @param {post[]} detail.posts * @param {post[]} [detail.select_all] - whether all posts to be selected by default, true if omitted * @returns */ show({ posts, select_all = true }) { if (this.showing) { return this.#promise; } ({ promise: this.#promise, resolve: this.#resolve } = Promise.withResolvers()); this.posts = posts; this.instance.resolve = this.#resolve; this.instance.posts = posts; this.instance.selectAll(select_all); this.instance.show = true; return this.#promise; } } const selector = new PostsSelector(); return { ProgressBoard, board, CustomDownloadPanel, panel, PostsSelector, selector }; } }, { id: 'downloader', desc: 'core zip download utils', dependencies: ['utils', 'api', 'settings'], async func() { const utils = require('utils'); const API = require('api'); const settings = require('settings'); // Performance record const perfmon = new utils.PerformanceManager(); perfmon.newTask('fetchPost'); perfmon.newTask('saveAs'); perfmon.newTask('_fetchBlob'); class DownloaderItem { /** @typedef {'file' | 'folder'} downloader_item_type */ /** * Name of the item, CANNOT BE PATH * @type {string} */ name; /** @type {downloader_item_type} */ type; /** * @param {string} name * @param {downloader_item_type} type */ constructor(name, type) { this.name = name; this.type = type; } } class DownloaderFile extends DownloaderItem{ /** @type {Blob} */ data; /** @type {Date} */ date; /** @type {string} */ comment; /** * @param {string} name - name only, CANNOT BE PATH * @param {Blob} data * @param {Object} detail * @property {Date} [date] * @property {string} [comment] */ constructor(name, data, detail) { super(name, 'file'); this.data = data; Object.assign(this, detail); } zip(jszip_instance) { const z = jszip_instance ?? new JSZip(); const options = {}; this.date && (options.date = this.date); this.comment && (options.comment = this.comment); z.file(this.name, this.data, options); return z; } } class DownloaderFolder extends DownloaderItem { /** @type {Array<DownloaderFile | DownloaderFolder>} */ children; /** * @param {string} name - name only, CANNOT BE PATH * @param {Array<DownloaderFile | DownloaderFolder>} [children] */ constructor(name, children) { super(name, 'folder'); this.children = children && children.length ? children : []; } zip(jszip_instance) { const z = jszip_instance ?? new JSZip(); for (const child of this.children) { switch (child.type) { case 'file': { child.zip(z); break; } case 'folder': { const sub_z = z.folder(child.name); child.zip(sub_z); } } } return z; } } /** * @typedef {Object} JSZip */ /** * @typedef {Object} zipObject */ /** * Download one post in zip file * @param {Object} detail * @param {string} detail.service * @param {number | string} detail.creator_id * @param {number | string} detail.post_id * @param {function} [detail.callback] - called each time made some progress, with two numeric arguments: finished_steps and total_steps * @param {boolean} [detail.flatten_files] * @param {boolean} [detail.no_prefix] * @param {string[]} [detail.ext_filter] */ async function downloadPost({ service, creator_id, post_id, callback = function() {}, flatten_files, no_prefix, ext_filter } = {}) { const manager = new utils.ProgressManager(3, callback, { layer: 'root', service, creator_id, post_id }); const data = await manager.progress(API.Posts.post(service, creator_id, post_id)); const folder = await manager.progress(fetchPost({ api_data: data, manager, flatten_files, no_prefix, ext_filter })); const zip = folder.zip(); const filename = `${data.post.title}.zip`; manager.progress(await saveAs(filename, zip, manager)); } /** * @typedef {Object} PostInfo * @property {string} service, * @property {number} creator_id * @property {number} post_id */ /** * Download one or multiple posts in zip file, one folder for each post * @param {Object} detail * @param {PostInfo | PostInfo[]} detail.posts * @param {string} detail.filename - file name of final zip file to be delivered to the user * @param {*} detail.callback - Progress callback, like downloadPost's callback param * @param {boolean} [detail.flatten_files] * @param {boolean} [detail.no_prefix] * @param {string[]} [detail.ext_filter] */ async function downloadPosts({ posts, filename, callback = function() {}, flatten_files, no_prefix, ext_filter } = {}) { Array.isArray(posts) || (posts = [posts]); const manager = new utils.ProgressManager(posts.length + 1, callback, { layer: 'root', posts, filename }); // Fetch posts const post_folders = await Promise.all( posts.map(async post => { const data = await API.Posts.post(post.service, post.creator_id, post.post_id); const folder = await fetchPost({ api_data: data, manager, flatten_files, no_prefix, ext_filter }); await manager.progress(); return folder; }) ); // Merge all post's folders into one const folder = new DownloaderFolder(filename); post_folders.map(async post_folder => { folder.children.push(post_folder); }) // Convert folder to zip const zip = folder.zip(); // Deliver to user await manager.progress(saveAs(filename, zip, manager)); } /** * Fetch one post * @param {Object} detail * @param {Object} detail.api_data - kemono post api returned data object * @param {utils.ProgressManager} detail.manager * @param {boolean} [detail.flatten_files] * @param {boolean} [detail.no_prefix] * @param {string[]} [detail.ext_filter] * @returns {Promise<DownloaderFolder>} */ async function fetchPost({ api_data, manager, flatten_files, no_prefix, ext_filter }) { const perfmon_run = perfmon.run('fetchPost', api_data); const sub_manager = manager.sub(0, manager.callback, { layer: 'post', data: api_data }); flatten_files = flatten_files !== undefined ? flatten_files : settings.flatten_files; no_prefix = no_prefix !== undefined ? no_prefix : settings.no_prefix; const date = new Date(api_data.post.edited); const folder = new DownloaderFolder(escapePath(api_data.post.title), [ ...settings.save_apijson ? [new DownloaderFile('data.json', JSON.stringify(api_data))] : [], ...settings.save_content ? [new DownloaderFile('content.html', api_data.post.content, { date })] : [] ]); let attachments_folder = folder; if (api_data.post.attachments.length && !flatten_files) { attachments_folder = new DownloaderFolder('attachments'); folder.children.push(attachments_folder); } const tasks = [ // api_data.post.file (async function(file) { if (!file.path) { return; } const ext = getFileExt(file); if (ext_filter && ext_filter.length && !ext_filter.includes(ext)) { return; } sub_manager.add(); const prefix = no_prefix ? '' : 'file-'; const ori_name = getFileName(file); const name = escapePath(prefix + ori_name); const url = getFileUrl(file); const blob = await sub_manager.progress(fetchBlob(url, sub_manager)); folder.children.push(new DownloaderFile( name, blob, { date, comment: JSON.stringify(file) } )); }) (api_data.post.file), // api_data.post.attachments ...api_data.post.attachments.map(async (attachment, i) => { if (!attachment.path) { return; } const ext = getFileExt(attachment); if (ext_filter && ext_filter.length && !ext_filter.includes(ext)) { return; } sub_manager.add(); const prefix = no_prefix ? '' : `${ utils.fillNumber(i+1, api_data.post.attachments.length.toString().length) }-`; const ori_name = getFileName(attachment); const name = escapePath(prefix + ori_name); const url = getFileUrl(attachment); const blob = await sub_manager.progress(fetchBlob(url, sub_manager)); attachments_folder.children.push(new DownloaderFile( name, blob, { date, comment: JSON.stringify(attachment) } )); }), ]; await Promise.all(tasks); // Make sure sub_manager finishes even when no async tasks if (sub_manager.steps === 0) { sub_manager.steps = 1; await sub_manager.progress(); } perfmon_run.stop(); return folder; /** * @typedef {Object} kemono_file * @property {string} path * @property {string} [name] */ /** * @param {kemono_file} file * @returns {string} */ function getFileName(file) { return file.name || file.path.slice(file.path.lastIndexOf('/')+1); } /** * @param {kemono_file} file * @returns {string} */ function getFileUrl(file) { return (file.server ?? 'https://n1.kemono.su') + '/data' + file.path; } /** * @param {kemono_file} file * @returns {string} file extension name, not including '.' */ function getFileExt(file) { const name = getFileName(file); return name.slice(name.lastIndexOf('.') + 1); } } /** * Deliver zip file to user * @param {string} filename - filename (with extension, e.g. "file.zip") * @param {JSZip} zip, * @param {string | null} comment - zip file comment */ async function saveAs(filename, zip, manager, comment=null) { const perfmon_run = perfmon.run('saveAs', filename); const sub_manager = manager.sub(100, manager.callback, { layer: 'zip', filename, zip }); const options = { type: 'blob' }; comment !== null && (options.comment = comment); const blob = await zip.generateAsync(options, metadata => sub_manager.progress(null, metadata.percent)); const url = URL.createObjectURL(blob); const a = $$CrE({ tagName: 'a', attrs: { download: filename, href: url } }); a.click(); perfmon_run.stop(); } /** * Fetch blob data from given url \ * Queued function of _fetchBlob * @param {string} url * @param {utils.ProgressManager} [manager] * @returns {Promise<Blob>} */ function fetchBlob(...args) { if (!fetchBlob.initialized) { queueTask.fetchBlob = { max: 3, sleep: 0 }; fetchBlob.initialized = true; } return queueTask(() => _fetchBlob(...args), 'fetchBlob'); } /** * Fetch blob data from given url * @param {string} url * @param {utils.ProgressManager} [manager] * @param {number} [retry=3] - times to retry before throwing an error * @returns {Promise<Blob>} */ async function _fetchBlob(url, manager, retry = 3) { const perfmon_run = perfmon.run('_fetchBlob', url); const sub_manager = manager.sub(0, manager.callback, { layer: 'file', url }); const blob = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', async onprogress(e) { sub_manager.steps = e.total; await sub_manager.progress(null, e.loaded); }, onload(e) { e.status === 200 ? resolve(e.response) : onerror(e) }, onerror }); async function onerror(err) { if (retry) { await sub_manager.progress(null, -1); const result = await _fetchBlob(url, manager, retry - 1); resolve(result); } else { reject(err) } } }); perfmon_run.stop(); return blob; } /** * Replace unallowed special characters in a path part * @param {string} path - a part of path, such as a folder name / file name */ function escapePath(path) { // Replace special characters const chars_bank = { '\\': '\', '/': '/', ':': ':', '*': '*', '?': '?', '"': "'", '<': '<', '>': '>', '|': '|' }; for (const [char, replacement] of Object.entries(chars_bank)) { path = path.replaceAll(char, replacement); } // Disallow ending with dots path.endsWith('.') && (path += '_'); return path; } return { downloadPost, downloadPosts, fetchPost, saveAs, perfmon }; } }, { id: 'settings', detectDom: 'html', dependencies: 'dependencies', params: ['GM_setValue', 'GM_getValue'], async func(GM_setValue, GM_getValue) { // settings 数据,所有设置项在这里配置 /** * @typedef {Object} setting * @property {string} title - 设置项名称 * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素 * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值 * @property {string} storage - GM存储key * @property {*} default - 用户未设置过时的默认值(初始值) */ /** @type {setting[]} */ const settings_data = [{ title: CONST.Text.SaveContent, caption: 'content.html', key: 'save_content', storage: 'save_content', default: true, }, { title: CONST.Text.SaveApijson, caption: 'data.json', key: 'save_apijson', storage: 'save_apijson', default: true, }, { title: CONST.Text.NoPrefix, key: 'no_prefix', storage: 'no_prefix', default: false, }, { title: CONST.Text.FlattenFiles, caption: CONST.Text.FlattenFilesCaption, key: 'flatten_files', storage: 'flatten_files', default: false }]; // settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数 const settings = {}; settings_data.forEach(setting => { Object.defineProperty(settings, setting.key, { get() { return GM_getValue(setting.storage, setting.default); }, set(val) { GM_setValue(setting.storage, !!val); } }); }); // 创建设置界面 const container = $$CrE({ tagName: 'div', styles: { all: 'initial', position: 'fixed' } }) const app_elm = $CrE('div'); app_elm.innerHTML = ` <q-layout view="hhh lpr fff"> <q-dialog v-model="show"> <q-card> <q-card-section> <div class="text-h6">${ CONST.Text.Settings }</div> </q-card-section> <q-card-section style="max-height: 75vh;" class="scroll"> <q-list> <q-item tag="label" v-for="setting of settings_data" v-ripple> <q-item-section> <q-item-label>{{ setting.title }}</q-item-label> <q-item-label caption v-if="setting.caption">{{ setting.caption }}</q-item-label> </q-item-section> <q-item-section avatar> <q-toggle color="primary" v-model="settings[setting.key]" @update:model-value="val => settings[setting.key] = val"></q-toggle> </q-item-section> </q-item> </q-list> </q-card-section> </q-card> </q-dialog> </q-layout> `; container.append(app_elm); $('html').append(container); const app = Vue.createApp({ data() { return { show: false, settings_data, settings }; }, mounted() { GM_registerMenuCommand(CONST.Text.Settings, e => this.show = true); GM_addValueChangeListener('settings', () => { this.save_content = settings.save_content; this.save_apijson = settings.save_apijson; }); } }); app.use(Quasar); app.mount(app_elm); return settings; } }, { id: 'user-interface', dependencies: ['utils', 'api', 'downloader', 'gui', 'settings'], async func() { const utils = require('utils'); const api = require('api'); const downloader = require('downloader'); const settings = require('settings'); const gui = require('gui'); let downloading = false; let selector = null; // Console User Interface const ConsoleUI = utils.window.ZIP = { version: GM_info.script.version, require, api, downloader, get ui() { return require('user-interface') }, downloadCurrentPost, downloadCurrentCreator, userDownload, getPageType }; // Menu User Interface GM_registerMenuCommand(CONST.Text.DownloadPost, downloadCurrentPost); GM_registerMenuCommand(CONST.Text.DownloadCreator, downloadCurrentCreator); GM_registerMenuCommand(CONST.Text.DownloadCustom, downloadCustom); // Graphical User Interface // Make button const dlbtn = $$CrE({ tagName: 'button', styles: { 'background-color': 'transparent', 'color': 'white', 'border': 'transparent' }, listeners: [['click', userDownload]] }); const dltext = $$CrE({ tagName: 'span', props: { innerText: CONST.Text.DownloadZip } }); const dlprogress = $$CrE({ tagName: 'span', styles: { display: 'none', 'margin-left': '10px' } }); dlbtn.append(dltext); dlbtn.append(dlprogress); const board = gui.board; // Place button each time a new action panel appears (meaning navigating into a post page) let observer; detectDom({ selector: '.post__actions, .user-header__actions', callback: action_panel => { // Hide dlprogress, its content is still for previous page dlprogress.style.display = 'none'; // Append to action panel action_panel.append(dlbtn); // Disconnect old observer observer?.disconnect(); // Observe action panel content change, always put download button in last place observer = detectDom({ root: action_panel, selector: 'button', callback: btn => btn !== dlbtn && action_panel.append(dlbtn) }); } }); return { ConsoleUI, dlbtn, dltext, get ['selector']() { return selector; }, downloadCurrentPost, downloadCurrentCreator, getCurrentPost, getCurrentCreator, getPageType } function userDownload() { const page_type = getPageType(); const func = ({ post: downloadCurrentPost, creator: downloadCurrentCreator })[page_type] ?? function() {}; return func(); } async function downloadCurrentPost() { const post_info = getCurrentPost(); if (downloading) { return; } if (!post_info) { return; } try { downloading = true; dlprogress.style.display = 'inline'; dltext.innerText = CONST.Text.Downloading; board.minimized = false; board.closed = false; board.clear(); await downloader.downloadPost({ service: post_info.service, creator_id: post_info.creator_id, post_id: post_info.post_id, callback: on_progress }); } finally { downloading = false; dltext.innerText = CONST.Text.DownloadZip; } function on_progress(finished_steps, total_steps, manager) { const info = manager.info; info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`); const progress = { finished: finished_steps, total: total_steps }; info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress); info.layer === 'post' && board.update(info.data.post.title, progress); info.layer === 'file' && board.update(info.url, progress); info.layer === 'zip' && board.update(info.filename, progress); board.closed = false; } } async function downloadCurrentCreator() { const creator_info = getCurrentCreator(); if (downloading) { return; } if (!creator_info) { return; } try { downloading = true; Quasar.Loading.show({ message: CONST.Text.FetchingCreatorPosts }); const [profile, selected_posts] = await Promise.all([ api.Creators.profile(creator_info.service, creator_info.creator_id), selectCreatorPosts(creator_info, () => Quasar.Loading.hide()) ]); if (selected_posts) { dlprogress.style.display = 'inline'; dltext.innerText = CONST.Text.Downloading; board.minimized = false; board.closed = false; board.clear(); await downloader.downloadPosts({ posts: selected_posts.map(post => ({ service: creator_info.service, creator_id: creator_info.creator_id, post_id: post.id })), filename: `${profile.name}.zip`, callback: on_progress }); } } finally { downloading = false; dltext.innerText = CONST.Text.DownloadZip; } function on_progress(finished_steps, total_steps, manager) { const info = manager.info; info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`); const progress = { finished: finished_steps, total: total_steps }; info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress); info.layer === 'post' && board.update(info.data.post.title, progress); info.layer === 'file' && board.update(info.url, progress); info.layer === 'zip' && board.update(info.filename, progress); board.closed = false; } } async function downloadCustom() { if (downloading) { return; } try { const post_info = getCurrentPost(); const creator_info = getCurrentCreator(); const info = post_info ?? creator_info ?? {}; const dl_info = await gui.panel.show({ download_type: post_info ? 'post' : (creator_info ? 'creator' : undefined), service: info.service ?? undefined, creator_id: info.creator_id ?? undefined, post_id: info.post_id ?? undefined, autoclose: false, no_prefix: settings.no_prefix, flatten_files: settings.flatten_files }); if (dl_info !== null) { downloading = true; if (dl_info.download_type === 'post') { gui.panel.close(); board.minimized = false; board.closed = false; board.clear(); await downloader.downloadPost({ service: dl_info.service, creator_id: dl_info.creator_id, post_id: dl_info.post_id, no_prefix: dl_info.no_prefix, flatten_files: dl_info.flatten_files, ext_filter: dl_info.ext_filter, callback: on_progress }); } else if (dl_info.download_type === 'creator') { gui.panel.loading = true; Assert(creator_info !== null, 'dl_info.download_type === "creator" but creator_info is null', TypeError); const [profile, selected_posts] = await Promise.all([ api.Creators.profile(creator_info.service, creator_info.creator_id), selectCreatorPosts(creator_info, () => { gui.panel.loading = false; gui.panel.close(); }) ]); if (selected_posts) { dlprogress.style.display = 'inline'; dltext.innerText = CONST.Text.Downloading; board.minimized = false; board.closed = false; board.clear(); await downloader.downloadPosts({ posts: selected_posts.map(post => ({ service: creator_info.service, creator_id: creator_info.creator_id, post_id: post.id, })), no_prefix: dl_info.no_prefix, flatten_files: dl_info.flatten_files, ext_filter: dl_info.ext_filter, filename: `${profile.name}.zip`, callback: on_progress }); } } } } finally { downloading = false; dltext.innerText = CONST.Text.DownloadZip; } function on_progress(finished_steps, total_steps, manager) { const info = manager.info; const progress = { finished: finished_steps, total: total_steps }; info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress); info.layer === 'post' && board.update(info.data.post.title, progress); info.layer === 'file' && board.update(info.url, progress); info.layer === 'zip' && board.update(info.filename, progress); board.closed = false; } } /** * User select creator's posts * @param {CreatorInfo} creator_info * @param {function} onAjaxLoad - callback when api ajax loaded * @returns */ async function selectCreatorPosts(creator_info, onAjaxLoad=function() {}) { const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id); typeof onAjaxLoad === 'function' && onAjaxLoad(); const selected_posts = await gui.selector.show({ posts }); return selected_posts; } /** * User select creator's posts * @param {CreatorInfo} creator_info * @param {function} onAjaxLoad - callback when api ajax loaded * @returns */ async function _selectCreatorPosts(creator_info, onAjaxLoad=function() {}) { const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id); typeof onAjaxLoad === 'function' && onAjaxLoad(); const selected_posts = await new Promise((resolve, reject) => { if (!selector) { selector = new ItemSelector(); selector.setTheme('dark'); selector.elements.container.style.setProperty('z-index', '1'); } selector.show({ text: CONST.Text.SelectAll, children: posts.map(post => ({ post, text: post.title })) }, { title: CONST.Text.DownloadCreator, onok(e, json) { const posts = json.children.map(obj => obj.post); resolve(posts); }, oncancel: e => resolve(null), onclose: e => resolve(null) }); }); return selected_posts; } /** * @typedef {Object} PostInfo * @property {string} service, * @property {number} creator_id * @property {number} post_id */ /** * Get post info in current page * @returns {PostInfo | null} */ function getCurrentPost() { const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/; const match = location.pathname.match(regpath); if (!match) { return null; } else { return { service: match[1], creator_id: parseInt(match[2], 10), post_id: parseInt(match[3], 10) } } } /** * @typedef {Object} CreatorInfo * @property {string} service * @property {number} creator_id */ /** * Get creator info in current page * @returns {CreatorInfo | null} */ function getCurrentCreator() { const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)/; const match = location.pathname.match(regpath); if (!match) { return null; } else { return { service: match[1], creator_id: parseInt(match[2], 10) } } } /** @typedef { 'post' | 'creator' } page_type */ /** * @returns {page_type} */ function getPageType() { const matchers = { post: { type: 'regpath', value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/ }, creator: { type: 'func', value: () => /^\/([a-zA-Z]+)\/user\/(\d+)/.test(location.pathname) && !location.pathname.includes('/post/') }, } for (const [type, matcher] of Object.entries(matchers)) { if (FunctionLoader.testCheckers(matcher)) { return type; } } } }, }]); }) ();