// ==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;
}
}
}
},
}]);
}) ();