Convert json config in tasks to use cli arguments.

This commit is contained in:
arabcoders 2025-03-31 00:12:35 +03:00
parent 8f313d7f58
commit 1bda1893e4
6 changed files with 154 additions and 236 deletions

View file

@ -872,15 +872,12 @@ class HttpAPI(Common):
if not item.get("timer", None) or str(item.get("timer")).strip() == "": if not item.get("timer", None) or str(item.get("timer")).strip() == "":
item["timer"] = f"{random.randint(1,59)} */1 * * *" # noqa: S311 item["timer"] = f"{random.randint(1,59)} */1 * * *" # noqa: S311
if not item.get("cookies", None):
item["cookies"] = ""
if not item.get("config", None) or str(item.get("config")).strip() == "":
item["config"] = {}
if not item.get("template", None): if not item.get("template", None):
item["template"] = "" item["template"] = ""
if not item.get("cli", None):
item["cli"] = ""
try: try:
ins.validate(item) ins.validate(item)
except ValueError as e: except ValueError as e:

View file

@ -3,13 +3,15 @@ import json
import logging import logging
import os import os
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
from aiohttp import web from aiohttp import web
from app.library.Utils import arg_converter
from .config import Config from .config import Config
from .encoder import Encoder from .encoder import Encoder
from .Events import EventBus, Events, error, info, success from .Events import EventBus, Events, error, info, success
@ -28,8 +30,7 @@ class Task:
preset: str = "" preset: str = ""
timer: str = "" timer: str = ""
template: str = "" template: str = ""
cookies: str = "" cli: str = ""
config: dict[str, str] = field(default_factory=dict)
def serialize(self) -> dict: def serialize(self) -> dict:
return self.__dict__ return self.__dict__
@ -134,16 +135,20 @@ class Tasks(metaclass=Singleton):
with open(self._file) as f: with open(self._file) as f:
tasks = json.load(f) tasks = json.load(f)
except Exception as e: except Exception as e:
LOG.error(f"Failed to parse tasks from '{self._file}'. '{e}'.") LOG.error(f"Failed to parse tasks from '{self._file}'. '{e!s}'.")
return self return self
if not tasks or len(tasks) < 1: if not tasks or len(tasks) < 1:
LOG.info(f"No tasks were defined in '{self._file}'.") LOG.info(f"No tasks were defined in '{self._file}'.")
return self return self
need_update = False
for i, task in enumerate(tasks): for i, task in enumerate(tasks):
try: try:
task, task_status = self.clean_task(task)
task = Task(**task) task = Task(**task)
if task_status:
need_update = True
except Exception as e: except Exception as e:
LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.") LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.")
continue continue
@ -157,6 +162,10 @@ class Tasks(metaclass=Singleton):
LOG.exception(e) LOG.exception(e)
LOG.error(f"Failed to queue task '{i}: {task.name}'. '{e!s}'.") LOG.error(f"Failed to queue task '{i}: {task.name}'. '{e!s}'.")
if need_update:
LOG.info("Updating tasks file to remove old keys.")
self.save(self.get_all())
return self return self
def clear(self, shutdown: bool = False) -> "Tasks": def clear(self, shutdown: bool = False) -> "Tasks":
@ -213,17 +222,12 @@ class Tasks(metaclass=Singleton):
msg = "No URL found." msg = "No URL found."
raise ValueError(msg) raise ValueError(msg)
if not isinstance(task.get("cookies"), str): if task.get("cli"):
msg = "Invalid cookies type." try:
raise ValueError(msg) # noqa: TRY004 arg_converter(args=task.get("cli"))
except Exception as e:
if not isinstance(task.get("config"), dict): msg = f"Invalid cli options. '{e!s}'."
msg = "Invalid config type." raise ValueError(msg) from e
raise ValueError(msg) # noqa: TRY004
if not isinstance(task.get("template"), str):
msg = "Invalid template type."
raise ValueError(msg) # noqa: TRY004
return True return True
@ -263,6 +267,27 @@ class Tasks(metaclass=Singleton):
return self return self
def clean_task(self, task: dict) -> tuple[dict, bool]:
"""
Clean the task from old keys.
Args:
task (dict): The task to clean.
Returns:
tuple[dict, bool]: The cleaned task and a status if the task was cleaned.
"""
status = False
removedKeys = ["cookies", "config"]
for key in removedKeys:
if key in task:
status = True
task.pop(key)
return task, status
async def _runner(self, task: Task): async def _runner(self, task: Task):
""" """
Run the task. Run the task.
@ -283,16 +308,8 @@ class Tasks(metaclass=Singleton):
preset: str = str(task.preset or self._default_preset) preset: str = str(task.preset or self._default_preset)
folder: str = task.folder if task.folder else "" folder: str = task.folder if task.folder else ""
cookies: str = str(task.cookies) if task.cookies else ""
template: str = task.template if task.template else "" template: str = task.template if task.template else ""
cli: str = task.cli if task.cli else ""
config = task.config if task.config else {}
if isinstance(config, str) and config:
try:
config = json.loads(config)
except Exception as e:
LOG.error(f"Failed to parse json yt-dlp config for '{task.name}'. {e!s}")
return
LOG.info(f"Task '{task.id}: {task.name}' dispatched at '{timeNow}'.") LOG.info(f"Task '{task.id}: {task.name}' dispatched at '{timeNow}'.")
@ -307,9 +324,8 @@ class Tasks(metaclass=Singleton):
"url": task.url, "url": task.url,
"preset": preset, "preset": preset,
"folder": folder, "folder": folder,
"cookies": cookies,
"config": config,
"template": template, "template": template,
"cli": cli,
}, },
id=task.id, id=task.id,
), ),

View file

@ -434,12 +434,13 @@ def validate_url(url: str) -> bool:
return True return True
def arg_converter(args: str) -> dict: def arg_converter(args: str, remove_options: bool = False) -> dict:
""" """
Convert yt-dlp options to a dictionary. Convert yt-dlp options to a dictionary.
Args: Args:
args (str): yt-dlp options string. args (str): yt-dlp options string.
remove_options (bool): Remove default options.
Returns: Returns:
dict: yt-dlp options dictionary. dict: yt-dlp options dictionary.
@ -472,6 +473,14 @@ def arg_converter(args: str) -> dict:
if isinstance(matchFilter, set): if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)} diff["match_filter"] = {"filters": list(matchFilter)}
if remove_options:
for key in diff.copy():
if key in IGNORED_KEYS:
diff.pop(key, None)
if "_warnings" in diff:
diff.pop("_warnings", None)
from .encoder import Encoder from .encoder import Encoder
return json.loads(json.dumps(diff, cls=Encoder)) return json.loads(json.dumps(diff, cls=Encoder))

View file

@ -4,6 +4,7 @@ import logging
from .config import Config from .config import Config
from .DownloadQueue import DownloadQueue from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Utils import arg_converter
LOG = logging.getLogger("common") LOG = logging.getLogger("common")
@ -94,6 +95,14 @@ class Common:
msg = f"Failed to parse json yt-dlp config for '{url}'. {e!s}" msg = f"Failed to parse json yt-dlp config for '{url}'. {e!s}"
raise ValueError(msg) from e raise ValueError(msg) from e
cli = item.get("cli")
if cli and len(cli) > 1:
try:
config = arg_converter(args=cli, remove_options=True)
except Exception as e:
msg = f"Failed to parse yt-dlp cli options. {e!s}"
raise ValueError(msg) from e
return { return {
"url": url, "url": url,
"preset": preset, "preset": preset,

View file

@ -50,7 +50,6 @@
</span> </span>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="name" v-text="'Name'" /> <label class="label is-inline" for="name" v-text="'Name'" />
@ -81,14 +80,11 @@
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="folder"> <label class="label is-inline" for="preset">Preset</label>
Preset
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="form.preset" <select id="preset" class="is-fullwidth" v-model="form.preset"
:disabled="addInProgress || hasFormatInConfig" :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"> <option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }} {{ item.name }}
</option> </option>
@ -98,8 +94,10 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <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 <span>Select the preset to use for this URL. <span class="text-has-danger">If the
config.</span> <code>-f, --format</code> argument is present in the command line options, the preset and all
it's options will be ignored.</span>
</span>
</span> </span>
</div> </div>
</div> </div>
@ -126,7 +124,7 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="folder"> <label class="label is-inline" for="folder">
Download path Download path
@ -138,24 +136,25 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <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>Paths are relative to global download path, defaults to preset download path if set otherwise,
fallback root path if not set.</span>
</span> </span>
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="output_template"> <label class="label is-inline" for="output_template">
Output template Output template
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" id="output_template" placeholder="The output template to use" <input type="text" class="input" id="output_template" :disabled="addInProgress"
v-model="form.template" :disabled="addInProgress"> placeholder="Leave empty to use default template." v-model="form.template">
<span class="icon is-small is-left"><i class="fa-solid fa-file" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>The output template to use, if not set, it will defaults to <span>Use this output template if non are given with URL. if not set, it will defaults to
<code>{{ config.app.output_template }}</code>. <code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template" For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>. target="_blank">visit this url</NuxtLink>.
@ -164,41 +163,24 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-12">
<div class="field"> <div class="field">
<label class="label is-inline" for="config" <label class="label is-inline" for="cli_options">
v-tooltip="'Extends current global yt-dlp config. (JSON)'"> Command arguments for yt-dlp
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> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="config" v-model="form.config" :disabled="addInProgress" <input type="text" class="input" v-model="form.cli" id="cli_options" :disabled="addInProgress"
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea> placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail">
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <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 <span>yt-dlp cli arguments. Check <NuxtLink target="_blank"
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
caution For more info. Some arguments are ignored by default. Warning: Use with
some of those options can break yt-dlp or the frontend.</span> caution some of those options can break yt-dlp or the frontend. <span class="has-text-danger">If
</span> <code>-f, --format</code> argument is present, the preset and all it's options will be
</div> ignored</span>.
</div> </span>
<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> </span>
</div> </div>
</div> </div>
@ -221,12 +203,6 @@
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</p> </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>
</div> </div>
</form> </form>
@ -244,8 +220,6 @@ import { CronExpressionParser } from 'cron-parser'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast(); const toast = useToast();
const config = useConfigStore(); const config = useConfigStore();
const convertInProgress = ref(false);
const showAdvanced = useStorage('task.showAdvanced', false);
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false);
const import_string = ref(''); const import_string = ref('');
@ -269,10 +243,6 @@ const props = defineProps({
const form = reactive(props.task); const form = reactive(props.task);
onMounted(() => { onMounted(() => {
if (props.task?.config && (typeof props.task.config === 'object')) {
form.config = JSON.stringify(props.task.config, null, 4);
}
if (!props.task?.preset || '' === props.task.preset) { if (!props.task?.preset || '' === props.task.preset) {
form.preset = toRaw(config.app.default_preset); form.preset = toRaw(config.app.default_preset);
} }
@ -304,60 +274,17 @@ const checkInfo = async () => {
return; return;
} }
if (typeof form.config === 'object') { if (form?.cli && '' !== form.cli) {
form.config = JSON.stringify(form.config, null, 4); const options = await convertOptions(form.cli);
} if (null === options) {
return
if (form.config && form.config && !form.config.trim().startsWith('{')) {
await convertOptions();
}
if (form.config) {
try {
form.config = JSON.parse(form.config)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
} }
form.cli = form.cli.trim(" ")
} }
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) }); emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) });
} }
const convertOptions = async () => {
if (convertInProgress.value) {
return
}
try {
convertInProgress.value = true
const response = await convertCliOptions(form.config)
form.config = JSON.stringify(response.opts, null, 2)
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
} catch (e) {
toast.error(e.message)
} finally {
convertInProgress.value = false
}
}
const hasFormatInConfig = computed(() => {
if (!form.config) {
return false
}
try {
const config = JSON.parse(form.config)
return "format" in config
} catch (e) {
return false
}
})
const importItem = async () => { const importItem = async () => {
let val = import_string.value.trim() let val = import_string.value.trim()
if (!val) { if (!val) {
@ -384,7 +311,7 @@ const importItem = async () => {
return return
} }
if (form.config || form.url || form.timer) { if (form.url || form.timer) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) { if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return return
} }
@ -398,8 +325,8 @@ const importItem = async () => {
form.url = item.url form.url = item.url
} }
if (item.preset) { if (item.template) {
form.preset = item.preset form.template = item.template
} }
if (item.timer) { if (item.timer) {
@ -410,12 +337,19 @@ const importItem = async () => {
form.folder = item.folder form.folder = item.folder
} }
if (item.template) { if (item.cli) {
form.template = item.template form.cli = item.cli
} }
if (item.config) { if (item.preset) {
form.config = JSON.stringify(item.config, null, 2) // -- check if the preset exists in config.presets
const preset = config.presets.find(p => p.name === item.preset)
if (!preset) {
toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`)
form.preset = 'default'
} else {
form.preset = item.preset
}
} }
import_string.value = '' import_string.value = ''
@ -424,4 +358,33 @@ const importItem = async () => {
toast.error(`Failed to import string. ${e.message}`) toast.error(`Failed to import string. ${e.message}`)
} }
} }
const convertOptions = async args => {
try {
const response = await convertCliOptions(args)
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
return response.opts
} catch (e) {
toast.error(e.message)
}
return null;
}
const hasFormatInConfig = computed(() => {
if (!form?.cli) {
return false
}
if (form.cli.includes('-f') || form.cli.includes('--format')) {
return true
}
})
</script> </script>

View file

@ -39,8 +39,7 @@ div.is-centered {
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle"> <span class="subtitle">
The task runner is simple queue system that allows you to schedule downloads. The tasks are run at the The task runner is simple queue system that allows you to schedule downloads to run at the specific time.
scheduled time.
</span> </span>
</div> </div>
</div> </div>
@ -62,10 +61,6 @@ div.is-centered {
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)"> <a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div> </div>
</header> </header>
<div class="card-content"> <div class="card-content">
@ -86,11 +81,10 @@ div.is-centered {
<span class="icon"><i class="fa-solid fa-tv" /></span> <span class="icon"><i class="fa-solid fa-tv" /></span>
<span>{{ item.preset ?? config.app.default_preset }}</span> <span>{{ item.preset ?? config.app.default_preset }}</span>
</p> </p>
</div> <p class="is-text-overflow">
</div> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<div class="card-content" v-if="item?.raw"> <span>{{ item.cli }}</span>
<div class="content"> </p>
<pre><code>{{ filterItem(item) }}</code></pre>
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
@ -120,17 +114,6 @@ div.is-centered {
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" /> icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" />
</div> </div>
</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> </div>
</template> </template>
@ -202,6 +185,8 @@ const resetForm = (closeForm = false) => {
} }
const updateTasks = async items => { const updateTasks = async items => {
let data = {}
try { try {
addInProgress.value = true addInProgress.value = true
@ -213,7 +198,7 @@ const updateTasks = async items => {
body: JSON.stringify(items), body: JSON.stringify(items),
}) })
const data = await response.json() data = await response.json()
if (200 !== response.status) { if (200 !== response.status) {
toast.error(`Failed to update task. ${data.error}`); toast.error(`Failed to update task. ${data.error}`);
@ -224,7 +209,7 @@ const updateTasks = async items => {
resetForm(true) resetForm(true)
return true return true
} catch (e) { } catch (e) {
toast.error(`Failed to update task. ${data.error}`); toast.error(`Failed to update task. ${data?.error ?? e.message}`);
} finally { } finally {
addInProgress.value = false addInProgress.value = false
} }
@ -272,11 +257,6 @@ const updateItem = async ({ reference, task }) => {
resetForm(true) resetForm(true)
} }
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = item => { const editItem = item => {
task.value = item task.value = item
taskRef.value = item.id taskRef.value = item.id
@ -327,21 +307,8 @@ const runNow = item => {
data.template = item.template data.template = item.template
} }
if (item.cookies) { if (item.cli) {
data.cookies = item.cookies data.cli = item.cli
}
if (item.config) {
if (typeof item.config === 'object') {
data.config = JSON.stringify(item.config)
}
try {
JSON.parse(data.config)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return
}
} }
socket.emit('add_url', data) socket.emit('add_url', data)
@ -359,69 +326,26 @@ const statusHandler = async stream => {
} }
const exportItem = async item => { 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)) const info = JSON.parse(JSON.stringify(item))
preset = JSON.parse(JSON.stringify(preset))
let data = { let data = {
name: info.name, name: info.name,
url: info.url, url: info.url,
preset: 'default', preset: info.preset,
timer: info.timer, timer: info.timer,
folder: info.folder, folder: info.folder,
template: info.template,
config: info.config,
} }
// -- merge preset options with task args. if (info.template) {
let args = {} data.template = info.template
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) { if (info.cli) {
args.postprocessors = preset.postprocessors data.cli = info.cli
}
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['_type'] = 'task'
data['_version'] = '1.1' data['_version'] = '2.0'
return copyText(base64UrlEncode(JSON.stringify(data))); return copyText(base64UrlEncode(JSON.stringify(data)));
} }