// ==UserScript==
// @name Kemono zip download
// @name:zh-CN Kemono 下载为ZIP文件
// @namespace https://greasyfork.org/users/667968-pyudng
// @version 0.20
// @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
// @resource vue-js https://unpkg.com/[email protected]/dist/vue.global.prod.js
// @resource vue-js https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.min.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
// @resource quasar-icon-bak https://google-fonts.mirrors.sjtug.sjtu.edu.cn/css?family=Roboto:100,300,400,500,700,900|Material+Icons
// @resource quasar-css-bak https://unpkg.com/[email protected]/dist/quasar.prod.css
// @resource quasar-js-bak https://unpkg.com/[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
// @grant GM_setClipboard
// @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 Vue Quasar */
(function __MAIN__() {
'use strict';
const CONST = {
TextAllLang: {
DEFAULT: 'en-US',
'zh-CN': {
DownloadZip: '下载为ZIP文件',
DownloadPost: '下载当前作品为ZIP文件',
DownloadCreator: '下载当前创作者为ZIP文件',
DownloadCustom: '自定义下载',
Downloading: '正在下载...',
FetchingCreatorPosts: '正在获取创作者作品列表...',
SaveDirMissingTitle: '保存位置未设置',
SaveDirMissing: '您目前开启了FileSystemAPI保存文件,请先在设置中选择文件保存位置',
Settings: {
Title: '设置',
SaveContent: '保存文字内容到html文件',
SaveApijson: '保存api结果到json文件',
NoPrefix: '保存文件时不添加文件名前缀',
FlattenFiles: '保存主文件和附件到同一级文件夹',
FlattenFilesCaption: '主文件一般是封面图',
NumberPrefix: '使用数字作为主文件的前缀',
NumberPrefixCaption: '一般主文件使用"file-"作为前缀,开启此选项后将使用和附件一样的数字作为前缀,以便排序',
UseFileSystemAPI: '使用FileSystemAPI保存文件',
UseFileSystemAPICaption: '使用此API理论上可以保存更大的文件,但可能每次页面刷新后都需要用户重新手动授权',
FileSystemAPINotSupported: '您的浏览器不支持此功能;最新版的Chrome或Edge浏览器支持此功能',
FileSystemAPINotSupportedTitle: '啊哦Σ(っ °Д °;)っ',
NoFolderSelected: '未选择',
SaveDir: 'FileSystemAPI下载位置',
SaveDirCaption: '使用FileSystemAPI时保存到此位置,需要用户主动选择授权',
},
ProgressBoard: {
TotalProgress: '总进度',
TotalProgDesc: '共 {PostsCount} 篇内容需要下载',
PostProgDesc: '内有 {FilesCount} 个文件需要下载',
ZipProgDesc: '创建ZIP压缩包',
ProgressBoardTitle: '下载进度',
},
TaskDetail: {
Title: '任务详情',
TaskName: '任务名称:',
TaskDesc: '任务描述:',
TaskProgress: '任务进度:',
TaskProgDetail: '任务具体进度:',
CopyButton: '复制任务信息',
OkayButton: '朕知道了,下去吧',
},
CustomDownload: {
PopupTitle: '自定义下载',
TypeLabel: '下载...',
ServiceLabel: '发布平台',
CreatorLabel: '创作者ID',
PostLabel: '内容ID',
ExtFilterLabel: '只下载以下扩展名的文件',
ExtFilterTooltip: '留空以下载所有文件',
MoreOptions: '更多选项',
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',
SaveDirMissingTitle: 'Download location missing',
SaveDirMissing: 'You have enabled FileSystemAPI, please set FileSystemAPI download location before downloading',
Settings: {
Title: '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',
NumberPrefix: 'Use number as filename prefix for the main file',
NumberPrefixCaption: 'Replace "file-" prefix with number to sort it in order with other files',
UseFileSystemAPI: 'Use File API for saving ZIP files',
UseFileSystemAPICaption: 'File API supports saving larger file than usual, but requires user granting permission each time',
FileSystemAPINotSupported: 'This feature is not supported in your browser, please use latest chrome or edge to use it',
FileSystemAPINotSupportedTitle: 'Ah-oh Σ(っ °Д °;)っ',
NoFolderSelected: 'Not provided',
SaveDir: 'FileSystemAPI download location',
SaveDirCaption: 'Used when FileSystemAPI is enabled',
},
ProgressBoard: {
TotalProgress: 'Total Progress',
TotalProgDesc: '{PostsCount} posts to download in total',
PostProgDesc: '{FilesCount} files to download inside',
ZipProgDesc: 'Compressing all files into a zip file',
ProgressBoardTitle: 'Download Progress',
},
TaskDetail: {
Title: 'Task Detail',
TaskName: 'Name: ',
TaskDesc: 'Description: ',
TaskProgress: 'Progress: ',
TaskProgDetail: 'Progress detail: ',
CopyButton: 'Copy task info',
OkayButton: 'Okay',
},
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',
MoreOptions: 'More options',
Type: {
Post: 'single post',
Creator: 'creator posts'
},
Cancel: 'Cancel',
Download: 'Download',
},
PostsSelector: {
Title: 'Select posts',
OK: 'OK',
Cancel: 'Cancel',
SelectAll: 'Select All',
},
}
},
get Text() {
const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
return CONST.TextAllLang[i18n];
}
};
/**
* @typedef {Object} setting
* @property {string} title - 设置项名称
* @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素
* @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值
* @property {string} [storage] - GM存储key;提供时直接在存储空间读写,不提供时,仅作内存读写(实例间不共享,页面刷新重置)
* @property {boolean} [temp=false] - 是否在自定义下载中展示,以允许临时覆盖;不提供时默认为false
* @property {*} default - 用户未设置过时的默认值(初始值)
*/
/** @type {setting[]} */
CONST.Settings = [{
type: 'boolean',
title: CONST.Text.Settings.SaveContent,
caption: 'content.html',
key: 'save_content',
storage: 'save_content',
default: true,
temp: true,
}, {
type: 'boolean',
title: CONST.Text.Settings.SaveApijson,
caption: 'data.json',
key: 'save_apijson',
storage: 'save_apijson',
default: true,
temp: true,
}, {
type: 'boolean',
title: CONST.Text.Settings.FlattenFiles,
caption: CONST.Text.Settings.FlattenFilesCaption,
key: 'flatten_files',
storage: 'flatten_files',
default: false,
temp: true,
}, {
type: 'boolean',
title: CONST.Text.Settings.NoPrefix,
key: 'no_prefix',
storage: 'no_prefix',
default: false,
temp: true,
}, {
type: 'boolean',
title: CONST.Text.Settings.NumberPrefix,
caption: CONST.Text.Settings.NumberPrefixCaption,
key: 'number_prefix',
storage: 'number_prefix',
default: false,
temp: true,
}, {
type: 'boolean',
title: CONST.Text.Settings.UseFileSystemAPI,
caption: CONST.Text.Settings.UseFileSystemAPICaption,
key: 'fileapi',
storage: 'fileapi',
default: false,
temp: true,
}, {
type: 'folder',
title: CONST.Text.Settings.SaveDir,
caption: CONST.Text.Settings.SaveDirCaption,
key: 'savedir',
storage: false,
default: null,
temp: true,
}];
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 extends EventTarget {
/** @type {*} */
info;
/** @type {number} */
steps;
/** @type {number} */
finished;
/** @type {'none' | 'sub' | 'self'} */
error;
/** @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, info=undefined) {
super();
this.steps = steps;
this.info = info;
this.finished = 0;
this.error = 'none';
this.#children = [];
this.#broadcast('progress');
}
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, adds 1 to this.finished if omitted
* @param {boolean} [accept_reject=true] - whether to treat rejected promise as resolved; if true, callback will get error object in arguments; if not, progress function itself rejects
* @returns {Awaited<task>}
*/
async progress(promise, finished, accept_reject = true) {
let val;
try {
val = await Promise.resolve(promise);
} catch(err) {
this.newError('self', false);
if (!accept_reject) {
throw err;
}
}
try {
this.finished = (typeof finished === 'number' && finished >= 0) ? finished : this.finished + 1;
this.#broadcast('progress');
//this.finished === this.steps && this.#parent && this.#parent.progress();
} finally {
return val;
}
}
/**
* New error occured in manager's scope, update error status
* @param {'none' | 'sub' | 'self'} [error='self']
* @param {boolean} [callCallback=true]
*/
newError(error = 'self', callCallback = true) {
const error_level = ['none', 'sub', 'self'];
if (error_level.indexOf(error) <= error_level.indexOf(this.error)) { return; }
this.error = error;
this.#parent && this.#parent.newError('sub');
callCallback && this.#broadcast('error');
}
/**
* Creates a new ProgressManager as a sub-progress of this
* @param {number} [steps=0] - total steps count of the task
* @param {*} [info] - attach any data about the sub-manager if need
*/
sub(steps, info) {
const manager = new ProgressManager(steps ?? 0, info);
manager.#parent = this;
this.#children.push(manager);
this.#broadcast('sub');
return manager;
}
/**
* reset this to an empty manager
*/
reset() {
this.steps = 0;
this.finished = 0;
this.#parent = null;
this.#children = [];
this.#broadcast('reset');
}
#broadcast(evt_name) {
//this.callback(this.finished, this.steps, this);
this.dispatchEvent(new CustomEvent(evt_name, {
detail: {
type: evt_name,
manager: this
}
}));
}
get children() {
return [...this.#children];
}
get parent() {
return this.#parent;
}
}
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 StandbySuffix = '-bak';
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) => {
const resource_text = GM_getResourceText(dep.name) || GM_getResourceText(dep.name + StandbySuffix);
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: resource_text + load_code,
});
break;
}
case 'style': {
GM_addElement(document.head, 'style', {
textContent: resource_text,
});
resolve();
break;
}
}
});
}));
Quasar.setCssVar('primary', 'orange');
setTimeout(() => Quasar.Dark.set(true));
// some fixes
addStyle(`
#main h1 {
font-size: 2em;
line-height: 1.5;
letter-spacing: unset;
}
#main h2 {
font-size: 1.6rem;
line-height: 1.35;
letter-spacing: unset;
}
#main h3 {
font-weight: 400;
font-size: 1.5rem;
line-height: 1.5;
letter-spacing: inherit;
}
body.q-body--force-scrollbar-y {
overflow-y: unset !important;
}
`)
}
}, {
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', 'settings', 'utils'],
async func() {
const settings = require('settings');
const utils = require('utils');
class ProgressBoard {
app;
instance;
constructor() {
const that = this;
// 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="showing" :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.ProgressBoard.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-progress-item :manager="manager" v-if="manager"></q-progress-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 {
board: that,
showing: false,
minimized: false,
manager: null
}
},
methods: {
show(manager) {
this.manager = manager;
this.showing = true;
this.minimized = false;
}
},
mounted() {
that.instance = this;
}
});
this.app.use(Quasar);
// 嵌套进度组件
/**
* 嵌套进度组件设计:
* - 该组件和ProgressManager紧密耦合,所有参数信息均包含在ProgressManager实例中
* - 该组件仅需传入一个根ProgressManager实例,所有嵌套子组件均根据根实例分支而来
* - 传入的ProgressManager必须有着以下标准的info属性:
* - @property {'root' | 'blob' | 'api' | 'zip' | 'folder'} manager.info.type 用于展示对应图标
* - @property {string} manager.info.name 显示的主要文字,一个短语描述其任务
* - @property {string} [manager.info.desc] 显示的次要文字,一句话描述任务信息
*/
this.app.component('q-progress-item', {
name: 'QProgressItem',
props: [ 'manager' ],
template: `
<q-item class="column">
<q-linear-progress
:value="progress"
:color="color"
:indeterminate="indeterminate"
></q-linear-progress>
<q-expansion-item
expand-separator
v-if="sub_managers.length"
:icon="icon"
:label="manager.info.name"
:caption="manager.info.desc"
>
<q-progress-item
v-for="sub_manager of sub_managers"
:manager="sub_manager"
></q-progress-item>
</q-expansion-item>
<q-item v-else clickable @dblclick="showDetail">
<q-item-section avatar>
<q-icon :name="icon"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>{{ manager.info.name }}</q-item-label>
<q-item-label caption>{{ manager.info.desc }}</q-item-label>
</q-item-section>
</q-item>
</q-item>
`,
data() {
return {
finished: this.manager.finished,
total: this.manager.steps,
error: this.manager.error,
sub_managers: this.manager.children
}
},
computed: {
progress() {
return this.total !== 0 ? this.finished / this.total : 1;
},
indeterminate() {
return this.total === 0 && this.error !== 'self';
},
icon() {
return ({
root: 'folder_zip',
blob: 'description',
api: 'api',
zip: 'compress',
folder: 'folder'
})[this.manager.info.type] ?? 'draft'; /** @TODO 需要由调用方将标准化的参数填入ProgressManager的info字段 */
},
color() {
return ({
none: this.finished === this.total && this.total > 0 ? 'green' : 'blue',
sub: this.finished === this.total ? 'orange' : 'blue',
self: 'red'
})[this.error];
},
TaskTextInfo() {
const TaskDetail = CONST.Text.TaskDetail;
const info = this.manager.info;
return [
TaskDetail.TaskName + info.name,
TaskDetail.TaskDesc + info.desc,
TaskDetail.TaskProgress + `${ this.progress * 100 }%`,
TaskDetail.TaskProgDetail + `${ this.finished } / ${ this.total }`,
].join('\n');
},
},
methods: {
showDetail(e) {
const dialog = Quasar.Dialog.create({
title: CONST.Text.TaskDetail.Title,
message: '',
html: true,
color: 'primary',
ok: {
label: CONST.Text.TaskDetail.OkayButton
},
cancel: {
label: CONST.Text.TaskDetail.CopyButton
},
}).onOk(() => {
// Close popup
// Nothing to do yet
}).onCancel(() => {
// Copy task info
this.copyInfo();
}).onDismiss(() => {
// When dialog isdismissed, no matter how
unwatch();
});
const unwatch = this.$watch('TaskTextInfo', (newInfo, oldInfo) => {
dialog.update({
message: newInfo.replaceAll('\n', '<br>')
});
}, {
immediate: true
});
},
copyInfo() {
GM_setClipboard(this.TaskTextInfo, 'text');
}
},
watch: {
manager: {
handler(new_manager, old_manager) {
const that = this;
$AEL(new_manager, 'sub', e => {
that.sub_managers = new_manager.children;
});
$AEL(new_manager, 'progress', e => {
that.finished = new_manager.finished;
that.total = new_manager.steps;
});
$AEL(new_manager, 'error', e => {
that.error = new_manager.error;
});
$AEL(new_manager, 'reset', e => fullRefresh());
fullRefresh();
function fullRefresh() {
that.sub_managers = new_manager.children;
that.finished = new_manager.finished;
that.total = new_manager.steps;
that.error = new_manager.error;
}
},
immediate: true
}
}
});
this.app.mount(board);
}
/**
* @param {ProgressManager} manager
*/
show(manager) {
this.instance.show(manager);
}
}
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-expansion-item clickable expand-separator label="${ CONST.Text.CustomDownload.MoreOptions }">
<q-list>
<q-item tag="label" clickable v-for="setting of settings_data">
<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 side top>
<q-checkbox v-if="setting.type === 'boolean'" left-label v-model="temp_settings[setting.key]"></q-checkbox>
<div v-else-if="setting.type === 'folder'">
<span style="margin-right: 0.5em;">{{ temp_settings[setting.key]?.name ?? ${ escJsStr(CONST.Text.Settings.NoFolderSelected) } }}</span>
<q-btn icon="folder_open" color="primary" @click="requestFolder(setting.key)"></q-button>
</div>
</q-item-section>
</q-item>
</q-list>
</q-expansion-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,
temp_settings: {},
show: false,
autoclose: true,
download_type: null,
service: '',
creator_id: 0,
post_id: 0,
ext_filter: '',
loading: false,
settings_data: CONST.Settings.filter(setting => setting.temp),
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: {
/**
* @param {string} key
*/
async requestFolder(key) {
/** @type {Window} */
const win = utils.window;
if (!win.showDirectoryPicker) {
Quasar.Dialog.create({
title: CONST.Text.Settings.FileSystemAPINotSupportedTitle,
message: CONST.Text.Settings.FileSystemAPINotSupported,
});
return;
}
const dir_handle = await win.showDirectoryPicker({
id: key,
mode: 'readwrite',
startIn: 'downloads'
});
this.temp_settings[key] = dir_handle;
},
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),
settings: this.temp_settings
});
},
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', 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;
// 一次性的下载覆写设置
CONST.Settings.filter(s => s.temp).forEach(setting => {
this.instance.temp_settings[setting.key] = settings[setting.key];
});
// 展示下载框
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">${ CONST.Text.PostsSelector.SelectAll }</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 v-if="!!post.file?.path" fit="cover" ratio="1" loading="lazy" :src="getThumbnail(post.file)" :id="\`thumbnail-\${post.id}\`">
<q-tooltip>
<img :src="getThumbnail(post.file)" loading="lazy" class="image-preview">
</q-tooltip>
</q-img>
<q-icon v-else name="hide_image"></q-icon>
</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-btn icon="open_in_new" flat target="_blank" :href="\`/\${ post.service }/user/\${ post.user }/post/\${ post.id }\`"></q-btn>
</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;
}
/* 根据屏幕宽高确定图像大小 */
/* 当屏幕宽度大于高度时(横屏/宽屏) */
@media (min-aspect-ratio: 1/1) {
.image-preview {
height: 40vh;
width: auto;
}
}
/* 当屏幕高度大于等于宽度时(竖屏/方屏) */
@media (max-aspect-ratio: 1/1) {
.image-preview {
width: 40vw;
height: auto;
}
}
`));
this.app = Vue.createApp({
data() {
return {
selector: that,
show: false,
select_all: false,
/** @type {post[]} */
posts: [],
/** @type {post[]} */
selected_posts: [],
}
},
methods: {
getThumbnail(file) {
return 'https://img.kemono.su/thumbnail/data' + file.path;
},
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', 'gui'],
async func() {
const utils = require('utils');
const API = require('api');
const settings = require('settings');
const gui = require('gui');
// 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
*/
/**
* @typedef {Object} kemono_file
* @property {string} path
* @property {string} [name]
* @property {string} [server]
*/
/**
* one-time download config for current download
* @typedef {Object} download_config
* @property {boolean} [flatten_files] - put attachments into root folder instead of attachments folder
* @property {boolean} [no_prefix] - do not add filename prefixes
* @property {boolean} [number_prefix] - use number as filename prefix for main file too
* @property {string[]} [ext_filter] - download files with these extensions only
*/
/**
* Download one post in zip file, and show ProgressBoard
* @param {Object} detail
* @param {string} detail.service
* @param {number | string} detail.creator_id
* @param {number | string} detail.post_id
* @param {download_config} [detail.config] - config object passing to fetchPost
*/
async function downloadPost({
post,
config = {}
} = {}) {
if (!checkFSAPISaveDir(config)) { return; }
const { service, creator_id, post_id } = post;
const manager = new utils.ProgressManager(3, {
type: 'root',
name: CONST.Text.ProgressBoard.TotalProgress
});
gui.board.show(manager);
const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
const folder = await manager.progress(fetchPost({ api_data: data, manager, config }));
const zip = folder.zip();
const filename = `${data.post.title}.zip`;
manager.progress(await saveAs(filename, zip, manager, null, config));
}
/**
* @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 {download_config} [detail.config] - config object passing to fetchPost
*/
async function downloadPosts({
posts, filename,
config = {}
} = {}) {
if (!checkFSAPISaveDir(config)) { return; }
Array.isArray(posts) || (posts = [posts]);
const manager = new utils.ProgressManager(posts.length + 1, {
type: 'root',
name: CONST.Text.ProgressBoard.TotalProgress,
desc: replaceText(CONST.Text.ProgressBoard.TotalProgDesc, {
'{PostsCount}': posts.length.toString()
})
});
gui.board.show(manager);
// 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, config });
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, null, config));
}
/**
* Fetch one post
* @param {Object} detail
* @param {Object} detail.api_data - kemono post api returned data object
* @param {utils.ProgressManager} detail.manager
* @param {download_config} [detail.config] - config used for this download, default value for omitted ones is from settings
* @returns {Promise<DownloaderFolder>}
*/
async function fetchPost({ api_data, manager, config }) {
const perfmon_run = perfmon.run('fetchPost', api_data);
const sub_manager = manager.sub(0, {
type: 'folder',
name: api_data.post.title,
desc: replaceText(CONST.Text.ProgressBoard.PostProgDesc, {
'{FilesCount}': api_data.post.attachments.length + (api_data.post.file.path ? 1 : 0)
})
});
let { save_apijson, save_content, flatten_files, no_prefix, ext_filter, number_prefix } = config;
save_apijson = save_apijson !== undefined ? save_apijson : settings.save_apijson;
save_content = save_content !== undefined ? save_content : settings.save_content;
flatten_files = flatten_files !== undefined ? flatten_files : settings.flatten_files;
no_prefix = no_prefix !== undefined ? no_prefix : settings.no_prefix;
number_prefix = number_prefix !== undefined ? number_prefix : settings.number_prefix;
const date = new Date(api_data.post.edited);
const folder = new DownloaderFolder(escapePath(api_data.post.title), [
...save_apijson ? [new DownloaderFile('data.json', JSON.stringify(api_data))] : [],
...save_content ? [new DownloaderFile('content.html', api_data.post.content, { date })] : []
]);
// parent folder for attachments
let attachments_folder = folder;
if (api_data.post.attachments.length && !flatten_files) {
attachments_folder = new DownloaderFolder('attachments');
folder.children.push(attachments_folder);
}
/**
* @typedef {Object} file_obj
* @property {'main' | 'attachment'} type
* @property {kemono_file} file
* @property {number} index - zero-based index of file in its parent folder's all numbered files
*/
/** @type {file_obj[]} */
const file_objs = [
{
type: 'main',
file: api_data.post.file,
index: number_prefix ? 0 : -1
},
...api_data.post.attachments.map((attachment, i) => ({
type: 'attachment',
file: attachment,
index: (flatten_files && number_prefix) ? i + 1 : i
}))
];
const tasks = file_objs.map(async ({ type: file_type, file, index }) => {
if (!file.path) { return; }
const ext = getFileExt(file);
if (ext_filter && ext_filter.length && !ext_filter.includes(ext)) { return; }
sub_manager.add();
// Filename prefix
const file_number = index + 1;
const file_number_prefix = `${ utils.fillNumber(file_number, file_objs.length.toString().length) }-`;
const prefix = ({
main: no_prefix ? '' : (number_prefix ? file_number_prefix : 'file-'),
attachment: no_prefix ? '' : file_number_prefix
})[file_type];
// Filename
const ori_name = getFileName(file);
const name = escapePath(prefix + ori_name);
// Fetch blob
const url = getFileUrl(file);
const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
// Add to folder
if (blob) {
const parent_folder = ({
main: folder,
attachment: attachments_folder
})[file_type];
parent_folder.children.push(new DownloaderFile(
name, blob, {
date,
comment: JSON.stringify(file)
}
));
}
});
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;
/**
* @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 (getFileServer(file) ?? '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);
}
/**
* @param {kemono_file} file
* @returns {string} file extension name, not including '.'
*/
function getFileServer(file) {
if (file.server) {
return file.server;
}
const preview = [...api_data.attachments, ...api_data.previews].find(preview => preview.path === file.path);
return preview?.server ?? null;
}
}
/**
* Deliver zip file to user
* @param {string} filename - filename (with extension, e.g. "file.zip")
* @param {JSZip} zip,
* @param {ProgressManager} manager
* @param {string | null} [comment] - zip file comment
* @param {download_config} [config] - config used for this download, default value for omitted ones is from settings
*/
async function saveAs(filename, zip, manager, comment=null, config = {}) {
const perfmon_run = perfmon.run('saveAs', filename);
const sub_manager = manager.sub(100, {
type: 'zip',
name: filename,
desc: CONST.Text.ProgressBoard.ZipProgDesc
});
if (settings.fileapi) {
// FileSystemAPI
if (!checkFSAPISaveDir(config)) { return; }
const dir_handle = config.savedir ?? settings.savedir;
// Get existing file/folder names in savedir
const existing_fnames = [];
for await (const f of dir_handle.values()) {
existing_fnames.push(f.name);
}
// Generate filename
if (existing_fnames.includes(filename)) {
const ext_index = filename.lastIndexOf('.');
const base = filename.slice(0, ext_index);
const ext = filename.slice(ext_index + 1);
let new_fname = filename;
for (let i = 2; existing_fnames.includes(new_fname); i++) {
new_fname = `${ base } (${ i }).${ ext }`;
}
filename = new_fname;
}
// Create file
const file_handle = await dir_handle.getFileHandle(filename, { create: true });
const stream = await file_handle.createWritable({ mode: 'exclusive' });
// Generate zip file stream and write into file
await new Promise((resolve, reject) => {
const options = { type: 'uint8array' };
zip.generateInternalStream(
options
).on('data', (data, metadata) => {
stream.write(data);
sub_manager.progress(null, metadata.percent)
}).on('end', () => {
stream.close();
resolve();
}).resume();
});
} else {
// Traditional <a download>
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 \
* resolves null if error thrown
* Queued function of _fetchBlob
* @param {string} url
* @param {utils.ProgressManager} [manager]
* @returns {Promise<Blob | null>}
*/
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
* resolves null if error thrown
* @param {string} url
* @param {utils.ProgressManager} [manager]
* @param {utils.ProgressManager} [sub_manager] provided only in error-retry logic
* @param {number} [retry=3] - times to retry before throwing an error
* @returns {Promise<Blob | null>}
*/
async function _fetchBlob(url, manager, sub_manager = null, retry = 3) {
const perfmon_run = perfmon.run('_fetchBlob', url);
sub_manager = sub_manager ?? manager.sub(0, {
type: 'blob',
name: url.slice(url.lastIndexOf('/') + 1),
desc: url
});
try {
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) {
sub_manager.reset();
_fetchBlob(url, manager, sub_manager, retry - 1).then(result => {
perfmon_run.stop();
resolve(result);
}).catch(err => doReject(err));
} else {
doReject(err);
}
function doReject(err) {
sub_manager.newError('self');
perfmon_run.stop();
//reject(err);
resolve(null);
}
}
});
return blob;
} catch(err) {
throw err;
} finally {
perfmon_run.stop();
}
}
/**
* Check if FileSystemAPI is enabled but SaveDir not set
* If not, alert user and return false
* @param {download_config} - also checks this config for savedir existance
* @returns {boolean}
*/
function checkFSAPISaveDir(config) {
if (settings.fileapi && !settings.savedir && !config.savedir) {
Quasar.Dialog.create({
title: CONST.Text.SaveDirMissingTitle,
message: CONST.Text.SaveDirMissing
});
return false;
}
return true;
}
/**
* 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: ['utils', 'dependencies'],
params: ['GM_setValue', 'GM_getValue'],
async func(GM_setValue, GM_getValue) {
const utils = require('utils');
// settings 数据,所有设置项在这里配置
/** @type {setting[]} */
const settings_data = CONST.Settings;
// settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数
// 对于提供了storage键的,默认直接在GM存储空间读写;不提供storage键的,仅在内存中读写
const settings = {};
settings_data.forEach(setting => {
if (setting.storage) {
Object.defineProperty(settings, setting.key, {
get() {
return GM_getValue(setting.storage, setting.default);
},
set(val) {
GM_setValue(setting.storage, !!val);
}
});
} else {
settings[setting.key] = setting.default;
}
});
// 创建设置界面
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.Title }</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 v-if="setting.type === 'boolean'" color="primary" v-model="settings[setting.key]"></q-toggle>
<div v-else-if="setting.type === 'folder'">
<span style="margin-right: 0.5em;">{{ settings[setting.key]?.name ?? ${ escJsStr(CONST.Text.Settings.NoFolderSelected) } }}</span>
<q-btn icon="folder_open" color="primary" @click="requestFolder(setting.key)"></q-button>
</div>
</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
};
},
methods: {
/**
* @param {string} key
*/
async requestFolder(key) {
/** @type {Window} */
const win = utils.window;
if (!win.showDirectoryPicker) {
Quasar.Dialog.create({
title: CONST.Text.Settings.FileSystemAPINotSupportedTitle,
message: CONST.Text.Settings.FileSystemAPINotSupported,
});
return;
}
const dir_handle = await win.showDirectoryPicker({
id: key,
mode: 'readwrite',
startIn: 'downloads'
});
this.settings[key] = dir_handle;
},
},
mounted() {
GM_registerMenuCommand(CONST.Text.Settings.Title, 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'],
async func() {
const utils = require('utils');
const api = require('api');
const downloader = require('downloader');
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, downloadCustom, 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);
// 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;
await downloader.downloadPost({
post: {
service: post_info.service,
creator_id: post_info.creator_id,
post_id: post_info.post_id,
},
});
} finally {
downloading = false;
dltext.innerText = CONST.Text.DownloadZip;
}
}
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;
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`
});
}
} finally {
downloading = false;
dltext.innerText = CONST.Text.DownloadZip;
}
}
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,
});
if (dl_info !== null) {
downloading = true;
if (dl_info.download_type === 'post') {
gui.panel.close();
await downloader.downloadPost({
post: {
service: dl_info.service,
creator_id: dl_info.creator_id,
post_id: dl_info.post_id,
},
config: {
ext_filter: dl_info.ext_filter,
...dl_info.settings,
}
});
} 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;
await downloader.downloadPosts({
posts: selected_posts.map(post => ({
service: creator_info.service,
creator_id: creator_info.creator_id,
post_id: post.id,
})),
config: {
ext_filter: dl_info.ext_filter,
...dl_info.settings,
},
filename: `${profile.name}.zip`
});
}
}
}
} finally {
downloading = false;
dltext.innerText = CONST.Text.DownloadZip;
}
}
/**
* 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;
}
/**
* @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;
}
}
}
},
}]);
}) ();