Merge pull request #210 from arabcoders/dev

Standardize the form design & add import/export to all functions
This commit is contained in:
Abdulmohsen 2025-03-14 23:18:44 +03:00 committed by GitHub
commit 9adb6efbc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1221 additions and 1195 deletions

13
.vscode/launch.json vendored
View file

@ -43,6 +43,19 @@
"YTP_ACCESS_LOG": "true",
}
},
{
"name": "Node: Generate UI",
"request": "launch",
"runtimeArgs": [
"run",
"generate"
],
"runtimeExecutable": "yarn",
"type": "node",
"cwd": "${workspaceFolder}/ui",
"console": "internalConsole",
"outputCapture": "std",
},
{
"name": "Python: main.py (Deep)",
"type": "debugpy",

36
Pipfile.lock generated
View file

@ -27,11 +27,11 @@
},
"aiohappyeyeballs": {
"hashes": [
"sha256:0850b580748c7071db98bffff6d4c94028d0d3035acc20fd721a0ce7e8cac35d",
"sha256:18fde6204a76deeabc97c48bdd01d5801cfda5d6b9c8bbeb1aaaee9d648ca191"
"sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558",
"sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"
],
"markers": "python_version >= '3.9'",
"version": "==2.5.0"
"version": "==2.6.1"
},
"aiohttp": {
"hashes": [
@ -149,11 +149,11 @@
},
"attrs": {
"hashes": [
"sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e",
"sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"
"sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3",
"sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"
],
"markers": "python_version >= '3.8'",
"version": "==25.1.0"
"version": "==25.3.0"
},
"bidict": {
"hashes": [
@ -436,20 +436,20 @@
},
"curl-cffi": {
"hashes": [
"sha256:202145686c5ebc00635b9b15cc8afa82523f14197a640b618bb09d59e74889ba",
"sha256:26d61c5b5c59e9c4a006cba35feccd73c75ce981db0da89a3da88dd280ead7a0",
"sha256:429a14e724898b7001be2776caa87c294e8062f0ac652619df5854eeae6e156c",
"sha256:4818e074b61cb209bd8d4d0d03783313d4773e6b51f8b815e25aad9cc146a7b7",
"sha256:752a4686c062a8bd6e25961845ea812704ba089a0e6863957deb2a30590105bb",
"sha256:7968c3ea37bec96bbe4b623c0fa59c09d3cdaf173da2e0a516b19db52f05caa4",
"sha256:7b1177203e47d3248ccb250e63983eaddf39fce5332f91cc2993ea54cae5aa21",
"sha256:7f41bd0a5dafe3fe19319da1c31302a40634ec4779769ca3d954d8673eb2b4f2",
"sha256:81ba09622e68f8392236212dd0afd75dcd18cbe1be16467deb4d441d62cfc8a3",
"sha256:ea8f0511bc6882d30a985ddc53fa26d109f2977715d4851aef162bfa308cd2a8"
"sha256:04b1d23f0f54f94b8298ed417e6bece85a635d674723cde2b155da686efbf78f",
"sha256:15053d01c6a3e3c4c5331ce9e07e1dc31ca5aa063babca05d18b1b5aad369fac",
"sha256:1e60b8ecc80bfb0da4ff73ac9d194e80482b50ecbb8aefec1b0edaf45fafd80e",
"sha256:3969e4260ad4dab638fb6dbe349623f9f5f022435c7fd21daf760231380367fa",
"sha256:3e37b35268ca58492f54ed020ae4b50c33ee0debad4145db9f746f04ed466eb0",
"sha256:458f53c41bd76d90d8974d60c3a8a0dd902a1af1f9056215cf24f454bcedc6fd",
"sha256:59389773a1556e087120e91eac1e33f84f1599d853e1bc168b153e4cdf360002",
"sha256:cfc74f09e44d2d8d61b8e8fda3a7004b5bc0217a703fbbe9e16ef8caa1f3d4e4",
"sha256:f03f4b17dc679c82bd3c946feb1ad38749b2ad731d7c26daefaac857d1c72fd9",
"sha256:f1b0c7b7b81afca15a0e56c593d3c2bdcd4fd4c9ca49b9ded5b9d8076ba78ff9"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==0.9.0"
"markers": "python_version >= '3.9'",
"version": "==0.10.0"
},
"debugpy": {
"hashes": [

View file

@ -360,7 +360,10 @@ class Notification(metaclass=Singleton):
method=target.get("request", {}).get("method", "POST"),
url=target.get("request", {}).get("url"),
headers=[
TargetRequestHeader(key=h.get("key"), value=h.get("value"))
TargetRequestHeader(
key=str(h.get("key", "")).strip(),
value=str(h.get("value", "")).strip(),
)
for h in target.get("request", {}).get("headers", [])
],
),

View file

@ -129,7 +129,7 @@ class YTDLPOpts(metaclass=Singleton):
self.presets_opts = {}
self._item_opts = {}
if "format" in data and "not_set" == data["format"]:
if "format" in data and data["format"] in ["not_set", "default"]:
data["format"] = None
return data

View file

@ -1,202 +1,243 @@
<template>
<main class="columns mt-2">
<div class="column">
<form id="taskForm" @submit.prevent="checkInfo()">
<div class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</h1>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
Target name
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The notification target name, this is used to identify the target in the logs and
notifications.</span>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>
<span v-if="showImport">Hide</span>
<span v-else>Show</span>
import
</span>
</div>
</button>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="url">
Target URL
</label>
<div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress"
required>
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The URL to send the notification to.</span>
</span>
</div>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="method">
Request method
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
Import string
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item">
{{ item }}
</option>
</select>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The request method to use when sending the notification. This can be any of the standard HTTP
methods.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="type">
Request Type
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item">
{{ ucFirst(item) }}
</option>
</select>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The request type to use when sending the notification. This can be <code>JSON</code> or
<code>FORM</code> request.
</span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
</div>
<div class="column is-12 is-clearfix">
<div class="field">
<label class="label is-inline" for="on">
Select Events
<template v-if="form.on.length > 0">
- <NuxtLink @click="form.on = []">Clear selection</NuxtLink>
</template>
</label>
<div class="control has-icons-left">
<div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item">
{{ item }}
</option>
</select>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
Target name
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-paper-plane" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Subscribe to the events you want to listen for. When the event is triggered, the notification will
be sent to the target URL. If no events are selected, the notification will be sent for all events.
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The notification target name, this is used to identify the target in the logs and
notifications.</span>
</span>
</span>
</div>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline is-unselectable">
Optional Headers - <button type="button" class="has-text-link"
@click="form.request.headers.push({ key: '', value: '' });">Add Header
</button>
</label>
<div class="columns is-multiline is-mobile">
<template v-for="_, key in form.request.headers" :key="key">
<div class="column is-5">
<div class="field">
<div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].key"
:disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="url">
Target URL
</label>
<div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress"
required>
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The URL to send the notification to.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="method">
Request method
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item">
{{ item }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The request method to use when sending the notification. This can be any of the standard HTTP
methods.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="type">
Request Type
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item">
{{ ucFirst(item) }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The request type to use when sending the notification. This can be <code>JSON</code> or
<code>FORM</code> request.
</span>
</span>
</div>
</div>
<div class="column is-12 is-clearfix">
<div class="field">
<label class="label is-inline" for="on">
Select Events
<template v-if="form.on.length > 0">
- <NuxtLink @click="form.on = []">Clear selection</NuxtLink>
</template>
</label>
<div class="control has-icons-left">
<div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item">
{{ item }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-paper-plane" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Subscribe to the events you want to listen for. When the event is triggered, the notification will
be sent to the target URL. If no events are selected, the notification will be sent for all
events.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline is-unselectable">
Optional Headers - <button type="button" class="has-text-link"
@click="form.request.headers.push({ key: '', value: '' });">Add Header
</button>
</label>
<div class="columns is-multiline is-mobile">
<template v-for="_, key in form.request.headers" :key="key">
<div class="column is-5">
<div class="field">
<div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].key"
:disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The header key to send with the notification.</span>
</span>
</div>
<div class="column is-6">
<div class="field">
<div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].value"
:disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The header value to send with the notification.</span>
</span>
</div>
<div class="column is-1">
<div class="control">
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)"
:disabled="addInProgress">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The header key to send with the notification.</span>
</span>
</div>
<div class="column is-6">
<div class="field">
<div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].value"
:disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The header value to send with the notification.</span>
</span>
</div>
<div class="column is-1">
<div class="control">
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)"
:disabled="addInProgress">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
</div>
</template>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-exclamation" /></span>
<span class="has-text-danger">
If header key or value is empty, the header will not be sent.
</template>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-exclamation" /></span>
<span class="has-text-danger">
If header key or value is empty, the header will not be sent.
</span>
</span>
</span>
</div>
</div>
</div>
<div class="column is-12">
<div class="field is-grouped is-grouped-right">
<p class="control">
<button class="button is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="control">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
</div>
<div class="card-footer">
<p class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
</div>
</div>
</div>
</form>
@ -205,6 +246,7 @@
</template>
<script setup>
import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast();
const props = defineProps({
@ -231,6 +273,8 @@ const props = defineProps({
const form = reactive(props.item);
const requestMethods = ['POST', 'PUT'];
const requestType = ['json', 'form'];
const showImport = useStorage('showImport', false);
const import_string = ref('');
const checkInfo = async () => {
const required = ['name', 'request.url', 'request.method', 'request.type'];
@ -254,13 +298,73 @@ const checkInfo = async () => {
return;
}
let headers = []
for (const header of form.request.headers) {
if (!header.key || !header.value) {
form.request.headers.splice(form.request.headers.indexOf(header), 1);
continue
}
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() })
}
form.request.headers = headers;
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
}
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
toast.error('The import string is required.')
return
}
if (false === val.startsWith('{')) {
try {
val = base64UrlDecode(val)
} catch (e) {
console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return
}
}
try {
const item = JSON.parse(val)
if ('notification' !== item._type) {
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.target) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
if (item.name) {
form.name = item.name
}
if (item.url) {
form.url = item.url
}
if (item.request) {
form.request = item.request
}
if (item.on) {
form.on = item.on
}
import_string.value = ''
} catch (e) {
console.error(e)
toast.error(`Failed to import task. ${e.message}`)
}
}
</script>

View file

@ -1,38 +1,5 @@
<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<h1 class="is-pointer is-unselectable title is-5" @click="importExpanded = !importExpanded">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="importExpanded ? 'fa-arrow-up' : 'fa-arrow-down'" /></span>
<span>Import preset.</span>
</span>
</h1>
<form id="importForm" @submit.prevent="importPreset()" v-if="importExpanded">
<div class="box">
<label class="label" for="json_preset">
Base64 encoded JSON string.
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="json_preset" v-model="json_preset" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button form="importForm" class="button is-primary" :disabled="!json_preset" type="submit">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
</div>
</form>
</div>
<div class="column is-12">
<h1 class="is-unselectable is-pointer title is-5" @click="convertExpanded = !convertExpanded">
<span class="icon-text">
@ -40,10 +7,8 @@
<span>Convert yt-dlp cli options.</span>
</span>
</h1>
<form autocomplete="off" id="convertOpts" @submit.prevent="convertOptions()" v-if="convertExpanded">
<div class="box">
<label class="label" for="opts">
yt-dlp CLI options
</label>
@ -74,18 +39,54 @@
</div>
<div class="column is-12">
<form id="presetForm" @submit.prevent="checkInfo()">
<form autocomplete="off" id="presetForm" @submit.prevent="checkInfo()">
<div class="card">
<div class="card-header">
<div class="card-header-title">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</div>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
Import string
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name" v-text="'Name'" />
@ -112,8 +113,7 @@
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>. Note, as this key is required, you can set the value to
<code>not_set</code>
to use the default yt-dlp format.
<code>default</code> to let <code>yt-dlp</code> choose the best format.
</span>
</div>
</div>
@ -218,24 +218,23 @@
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="presetForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</div>
<div class="card-footer">
<div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="presetForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</div>
</div>
</div>
</form>
@ -247,6 +246,7 @@
</template>
<script setup>
import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit']);
const props = defineProps({
@ -276,8 +276,8 @@ const toast = useToast()
const convertInProgress = ref(false)
const form = reactive(JSON.parse(JSON.stringify(props.preset)))
const opts = ref('')
const json_preset = ref('')
const importExpanded = ref(false)
const import_string = ref('')
const showImport = useStorage('showImport', false);
const convertExpanded = ref(false)
onMounted(() => {
@ -395,10 +395,10 @@ const convertOptions = async () => {
}
}
const importPreset = async () => {
let val = json_preset.value.trim()
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
toast.error('The preset import string is required.')
toast.error('The import string is required.')
return
}
@ -406,13 +406,20 @@ const importPreset = async () => {
try {
val = base64UrlDecode(val)
} catch (e) {
toast.error('Invalid base64 string.', e)
console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return
}
}
try {
const preset = JSON.parse(val)
const item = JSON.parse(val)
if ('preset' !== item._type) {
toast.error(`Invalid import string. Expected type 'preset', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.format || form.args || form.postprocessors) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
@ -420,35 +427,35 @@ const importPreset = async () => {
}
}
if (preset.name) {
form.name = preset.name
if (item.name) {
form.name = item.name
}
if (preset.format) {
form.format = preset.format
if (item.format) {
form.format = item.format
}
if (preset.args) {
form.args = JSON.stringify(preset.args, null, 2)
if (item.args) {
form.args = JSON.stringify(item.args, null, 2)
}
if (preset.postprocessors) {
form.postprocessors = JSON.stringify(preset.postprocessors, null, 2)
if (item.postprocessors) {
form.postprocessors = JSON.stringify(item.postprocessors, null, 2)
}
if (preset.output_template) {
form.template = preset.output_template
if (item.output_template) {
form.template = item.output_template
}
if (preset.folder) {
form.folder = preset.folder
if (item.folder) {
form.folder = item.folder
}
json_preset.value = ''
importExpanded.value = false
import_string.value = ''
showImport.value = false
} catch (e) {
console.error(e)
toast.error(`Failed to import preset. ${e.message}`)
toast.error(`Failed to string. ${e.message}`)
}
}
</script>

View file

@ -1,188 +1,232 @@
<template>
<main class="columns mt-2">
<div class="column">
<form id="taskForm" @submit.prevent="checkInfo()" class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;">
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card">
<div class="card-header">
<div class="card-header-title is-text-overflow is-block">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</h1>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name" v-text="'Name'" />
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Task name is used to identify the task in the task list and in logs.</span>
</span>
<div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}" /></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="url" v-text="'URL'" />
<div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The channel or playlist URL.</span>
</span>
</div>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Download path
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders">
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Downloads are relative to download path, defaults to root path if not set.</span>
</span>
</div>
</div>
<div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string">
Import string
</label>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Preset
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="form.preset"
:disabled="addInProgress || hasFormatInConfig"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }}
</option>
</select>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div>
<div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the preset to use for this URL. The preset will be ignored if format key is present in
config.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="timer">
CRON expression timer.
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="timer" placeholder="leave empty to run once every hour."
v-model="form.timer" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-clock" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The CRON timer expression to use for this task. If not set, the task will run once an hour in a
random
minute. For more information on CRON expressions, see <NuxtLink to="https://crontab.guru/"
target="_blank">crontab.guru</NuxtLink>.
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can use this field to populate the data, using shared string.</span>
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="output_template">
Output template
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="output_template" placeholder="The output template to use"
v-model="form.template" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The output template to use, if not set, it will defaults to
<code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="config" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config or CLI options. <NuxtLink
v-if="form.config && (typeof form.config === 'string') && !form.config.trim().startsWith('{')"
@click="convertOptions()">Convert to JSON</NuxtLink>
</label>
<div class="control">
<textarea class="textarea" id="config" v-model="form.config" :disabled="addInProgress"
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name" v-text="'Name'" />
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Task name is used to identify the task in the task list and in logs.</span>
</span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span> Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control">
<textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress" />
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="url" v-text="'URL'" />
<div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The channel or playlist URL.</span>
</span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP
Cookie format.</span>
</span>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Preset
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="form.preset"
:disabled="addInProgress || hasFormatInConfig"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the preset to use for this URL. The preset will be ignored if format key is present in
config.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="timer">
CRON expression timer.
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="timer" placeholder="leave empty to run once every hour."
v-model="form.timer" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-clock" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The CRON timer expression to use for this task. If not set, the task will run once an hour in a
random
minute. For more information on CRON expressions, see <NuxtLink to="https://crontab.guru/"
target="_blank">crontab.guru</NuxtLink>.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="folder">
Download path
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders">
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Downloads are relative to download path, defaults to root path if not set.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="output_template">
Output template
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="output_template" placeholder="The output template to use"
v-model="form.template" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The output template to use, if not set, it will defaults to
<code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="config"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config or CLI options. <NuxtLink
v-if="form.config && (typeof form.config === 'string') && !form.config.trim().startsWith('{')"
@click="convertOptions()">Convert to JSON</NuxtLink>
</label>
<div class="control">
<textarea class="textarea" id="config" v-model="form.config" :disabled="addInProgress"
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span> Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with
caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="field">
<label class="label is-inline" for="cookies"
v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control">
<textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP
Cookie format.</span>
</span>
</div>
</div>
</div>
</div>
<div class="column is-12">
<div class="field is-grouped is-grouped-right">
<p class="control">
<button class="button is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="control">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
<p class="control">
<button class="button is-info" type="button" @click="showAdvanced = !showAdvanced">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Opts</span>
</button>
</p>
</div>
<div class="card-footer">
<p class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
<p class="card-footer-item">
<button class="button is-fullwidth is-info" type="button" @click="showAdvanced = !showAdvanced">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Advanced</span>
</button>
</p>
</div>
</div>
</form>
@ -195,14 +239,15 @@
<script setup>
import { useStorage } from '@vueuse/core'
import { parseExpression } from 'cron-parser'
import { request } from '~/utils/index'
import { CronExpressionParser } from 'cron-parser'
const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast();
const config = useConfigStore();
const convertInProgress = ref(false);
const showAdvanced = useStorage('task.showAdvanced', false);
const showImport = useStorage('showImport', false);
const import_string = ref('');
const props = defineProps({
reference: {
@ -244,9 +289,10 @@ const checkInfo = async () => {
if (form.timer) {
try {
parseExpression(form.timer);
CronExpressionParser.parse(form.timer);
} catch (e) {
toast.error('Invalid CRON expression.');
console.log(e)
toast.error(`Invalid CRON expression. ${e.message}`);
return;
}
}
@ -311,4 +357,71 @@ const hasFormatInConfig = computed(() => {
return false
}
})
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
toast.error('The import string is required.')
return
}
if (false === val.startsWith('{')) {
try {
val = base64UrlDecode(val)
} catch (e) {
console.error(e)
toast.error(`Failed to decode string. ${e.message}`)
return
}
}
try {
const item = JSON.parse(val)
if ('task' !== item._type) {
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.config || form.url || form.timer) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
if (item.name) {
form.name = item.name
}
if (item.url) {
form.url = item.url
}
if (item.preset) {
form.preset = item.preset
}
if (item.timer) {
form.timer = item.timer
}
if (item.folder) {
form.folder = item.folder
}
if (item.template) {
form.template = item.template
}
if (item.config) {
form.config = JSON.stringify(item.config, null, 2)
}
import_string.value = ''
} catch (e) {
console.error(e)
toast.error(`Failed to import string. ${e.message}`)
}
}
</script>

View file

@ -11,19 +11,19 @@
},
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"@sentry/nuxt": "^9.2.0",
"@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0",
"@pinia/nuxt": "^0.10.1",
"@sentry/nuxt": "^9.5.0",
"@vueuse/core": "^13.0.0",
"@vueuse/nuxt": "^13.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cron-parser": "^4.9.0",
"cron-parser": "^5.0.4",
"cronstrue": "^2.49.0",
"floating-vue": "^5.2.2",
"hls.js": "^1.4.12",
"moment": "^2.30.1",
"nuxt": "^3.16",
"pinia": "^2.1.7",
"pinia": "^3.0.1",
"socket.io-client": "^4.7.2",
"vue": "^3.4",
"vue-router": "^4.3.0",

View file

@ -66,9 +66,8 @@ div.is-centered {
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'"
@click.prevent="copyText(item.request.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
<a class="has-text-primary" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
@ -84,7 +83,7 @@ div.is-centered {
</p>
<p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span>
<span>{{ item.request.headers.map(h => h.key).join(', ') }}</span>
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
</p>
</div>
</div>
@ -116,6 +115,19 @@ div.is-centered {
</Message>
</div>
</div>
<div class="column is-12" v-if="notifications && notifications.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export notification target, We remove <code>Authorization</code> header key by default,
However this might not be enough to remove credentials from the exported data. it's your responsibility
to ensure that the exported data does not contain any sensitive information for sharing.
</li>
</ul>
</Message>
</div>
</div>
</template>
@ -301,4 +313,23 @@ const sendTest = async () => {
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
const exportItem = async item => {
let data = JSON.parse(JSON.stringify(item))
const keys = ['id', 'raw']
keys.forEach(k => {
if (data.hasOwnProperty(k)) {
delete data[k]
}
})
// -- Remove authorization headers
if (data.request?.headers && data.request.headers.length > 0) {
data.request.headers = data.request.headers.filter(h => h.key.toLowerCase() !== 'authorization')
}
data['_type'] = 'notification'
data['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(data)));
}
</script>

View file

@ -58,8 +58,8 @@ div.is-centered {
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="item.name" />
<div class="card-header-icon">
<a class="has-text-primary" v-tooltip="'Export preset.'" @click.prevent="copyItem(item)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
<a class="has-text-primary" v-tooltip="'Export preset.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid" :class="{
@ -116,12 +116,11 @@ div.is-centered {
v-if="!presets || presets.length < 1" />
</div>
</div>
<div class="column is-12">
<div class="column is-12" v-if="presets && presets.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export the preset, it doesn't include <code>Cookies</code> field for security reasons. The exported
string is URL safe base64 encoded JSON string.
When you export preset, it doesn't include <code>Cookies</code> field for security reasons.
</li>
</ul>
</Message>
@ -285,10 +284,10 @@ const editItem = (item) => {
onMounted(async () => (socket.isConnected ? await reloadContent(true) : ""));
const copyItem = (item) => {
const exportItem = item => {
let data = JSON.parse(JSON.stringify(item));
const keys = ["id", "default", "raw", "cookies"];
keys.forEach((key) => {
keys.forEach(key => {
if (key in data) {
delete data[key];
}
@ -311,6 +310,9 @@ const copyItem = (item) => {
userData[key] = data[key];
}
userData['_type'] = 'preset';
userData['_version'] = '1.0'
return copyText(base64UrlEncode(JSON.stringify(userData)));
};

View file

@ -39,8 +39,8 @@ div.is-centered {
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
The task runner is simply queuing given urls at the specified time. It's basically doing what you are doing
when you click the add button on the WebGUI, this just fancy way to automate that.
The task runner is simple queue system that allows you to schedule downloads. The tasks are run at the
scheduled time.
</span>
</div>
</div>
@ -59,9 +59,8 @@ div.is-centered {
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'"
@click.prevent="copyText(item.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
@ -121,12 +120,23 @@ div.is-centered {
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" />
</div>
</div>
<div class="column is-12" v-if="tasks && tasks.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export task, the preset settings is automatically merged into the task and the preset set to
<code>default</code> to be more portable. The exporter doesn't include <code>Cookies</code> field for
security reasons.
</li>
</ul>
</Message>
</div>
</div>
</template>
<script setup>
import moment from 'moment'
import { parseExpression } from 'cron-parser'
import { CronExpressionParser } from 'cron-parser'
import { request } from '~/utils/index'
const toast = useToast()
@ -293,7 +303,7 @@ onMounted(async () => {
const tryParse = expression => {
try {
return moment(parseExpression(expression).next().toISOString()).fromNow()
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
} catch (e) {
return "Invalid"
}
@ -347,4 +357,72 @@ const statusHandler = async stream => {
return
}
}
const exportItem = async item => {
let preset = config.presets.find(p => p.name === item.preset)
if (!preset) {
toast.error('Preset not found.')
return
}
const info = JSON.parse(JSON.stringify(item))
preset = JSON.parse(JSON.stringify(preset))
let data = {
name: info.name,
url: info.url,
preset: 'default',
timer: info.timer,
folder: info.folder,
template: info.template,
config: info.config,
}
// -- merge preset options with task args.
let args = {}
if (preset.args && Object.keys(preset.args).length > 0) {
for (const key of Object.keys(preset.args)) {
args[key] = preset.args[key]
}
}
const defaults = ['default', 'not_set']
if (preset.format && !defaults.includes(preset.format)) {
args.format = preset.format
}
if (preset.postprocessors && preset.postprocessors.length > 0) {
args.postprocessors = preset.postprocessors
}
if (preset.folder && !info.folder) {
data.folder = preset.folder
}
if (preset.template && !info.template) {
data.template = preset.template
}
if (!data.config || Object.keys(data.config).length < 1) {
data.config = {}
}
for (const key of Object.keys(args)) {
if (key in data.config && Array.isArray(args[key]) && Array.isArray(data.config[key])) {
data.config[key] = data.config[key].concat(args[key])
continue
}
if (data?.config[key]) {
continue
}
data.config[key] = args[key]
}
data['_type'] = 'task'
data['_version'] = '1.1'
return copyText(base64UrlEncode(JSON.stringify(data)));
}
</script>

File diff suppressed because it is too large Load diff