feat: improve task creation to support multiple URLs.
This commit is contained in:
parent
5e40e2f610
commit
f166c418f8
7 changed files with 472 additions and 86 deletions
51
API.md
51
API.md
|
|
@ -938,43 +938,68 @@ Notes:
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/tasks
|
### POST /api/tasks
|
||||||
**Purpose**: Create a new scheduled task.
|
**Purpose**: Create one or more scheduled tasks.
|
||||||
|
|
||||||
**Body**: Task object. Example:
|
**Body**: Single task object or array of task objects.
|
||||||
|
|
||||||
|
**Single task:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Task",
|
"name": "My Task",
|
||||||
"url": "https://youtube.com/...",
|
"url": "https://youtube.com/...",
|
||||||
"timer": "5 */2 * * *",
|
"timer": "5 */2 * * *",
|
||||||
"cookies": "",
|
"preset": "default",
|
||||||
"config": {},
|
"folder": "",
|
||||||
"template": "...",
|
"template": "",
|
||||||
"folder": "...",
|
"cli": "",
|
||||||
"preset": "...",
|
|
||||||
"auto_start": true,
|
"auto_start": true,
|
||||||
"handler_enabled": true,
|
"handler_enabled": true,
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response**:
|
**Multiple tasks:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"name": "First Task", "url": "https://...", "timer": "0 12 * * *", "preset": "default"},
|
||||||
|
{"url": "https://...", "folder": "custom/folder"},
|
||||||
|
{"url": "https://...", "preset": "different-preset", "template": "%(title)s.%(ext)s"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-URL Behavior:**
|
||||||
|
When providing an array, the first task must include all required fields. Subsequent tasks can omit all fields except `url`, which is required for each task. Which will be backfilled using the first task values. Except for two fields that are backfilled with special logic:
|
||||||
|
|
||||||
|
- `name`: Inferred from URL metadata, fallback: `task-{index}` if metadata is unavailable.
|
||||||
|
- `timer`:
|
||||||
|
- If no timer is provided in first task, timer will be disabled for all tasks.
|
||||||
|
- If timer is provided in first task, be extended by adding 5min interval for each subsequent task (e.g., `5 */2 * * *`, `10 */2 * * *`, `15 */2 * * *`, etc.) to prevent simultaneous execution this extends to hours.
|
||||||
|
|
||||||
|
**Response (single task):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "My Task",
|
"name": "My Task",
|
||||||
"url": "https://youtube.com/...",
|
"url": "https://youtube.com/...",
|
||||||
"timer": "5 */2 * * *",
|
"timer": "5 */2 * * *",
|
||||||
"cookies": "",
|
"preset": "default",
|
||||||
"config": {},
|
"folder": "",
|
||||||
"template": "...",
|
"template": "",
|
||||||
"folder": "...",
|
"cli": "",
|
||||||
"preset": "...",
|
|
||||||
"auto_start": true,
|
"auto_start": true,
|
||||||
"handler_enabled": true,
|
"handler_enabled": true,
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Response (multiple tasks):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"id": 1, "name": "First Task", "url": "https://...", ...},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
**Error Responses**:
|
**Error Responses**:
|
||||||
- `400 Bad Request` - Invalid request body or validation error
|
- `400 Bad Request` - Invalid request body or validation error
|
||||||
- `409 Conflict` - Task with the same name already exists
|
- `409 Conflict` - Task with the same name already exists
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,122 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TIMER_SLOTS_PER_HOUR: int = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _offset_timer(timer: str, index: int) -> str:
|
||||||
|
"""
|
||||||
|
Add deterministic offset to CRON timer based on index.
|
||||||
|
|
||||||
|
Uses 5-minute increments with 12 slots per hour (0-55 minutes).
|
||||||
|
After 12 items, expands to hours.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
hours_offset = index // 12
|
||||||
|
minutes_offset = (index % 12) * 5
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
index 0: +0min, index 5: +25min, index 11: +55min
|
||||||
|
index 12: +1hr 0min, index 24: +2hr 0min
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timer: CRON expression string (5 fields)
|
||||||
|
index: Task index for offset calculation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified CRON expression with offset applied, or original on error
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not timer or index <= 0:
|
||||||
|
return timer
|
||||||
|
|
||||||
|
try:
|
||||||
|
parts = timer.strip().split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
return timer
|
||||||
|
|
||||||
|
minute, hour, dom, month, dow = parts
|
||||||
|
|
||||||
|
hours_offset = index // TIMER_SLOTS_PER_HOUR
|
||||||
|
minutes_offset = (index % TIMER_SLOTS_PER_HOUR) * 5
|
||||||
|
|
||||||
|
original_minute = int(minute) if minute.isdigit() else 0
|
||||||
|
|
||||||
|
if minute.isdigit():
|
||||||
|
new_minute = (original_minute + minutes_offset) % 60
|
||||||
|
minute = str(new_minute)
|
||||||
|
elif "/" in minute:
|
||||||
|
base, step = minute.split("/", 1)
|
||||||
|
if base.isdigit():
|
||||||
|
new_base = (int(base) + minutes_offset) % 60
|
||||||
|
minute = f"{new_base}/{step}"
|
||||||
|
elif minute == "*":
|
||||||
|
minute = str(minutes_offset)
|
||||||
|
|
||||||
|
carry_hour = (original_minute + minutes_offset) // 60
|
||||||
|
|
||||||
|
if hour.isdigit():
|
||||||
|
new_hour = (int(hour) + hours_offset + carry_hour) % 24
|
||||||
|
hour = str(new_hour)
|
||||||
|
elif "/" in hour:
|
||||||
|
base, step = hour.split("/", 1)
|
||||||
|
if base.isdigit():
|
||||||
|
new_base = (int(base) + hours_offset + carry_hour) % 24
|
||||||
|
hour = f"{new_base}/{step}"
|
||||||
|
elif hour == "*" and (hours_offset > 0 or carry_hour > 0):
|
||||||
|
hour = str((hours_offset + carry_hour) % 24)
|
||||||
|
|
||||||
|
return f"{minute} {hour} {dom} {month} {dow}"
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning(f"Failed to offset timer '{timer}': {e}")
|
||||||
|
return timer
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_info(url: str, preset: str) -> tuple[str | None, str | None]:
|
||||||
|
"""
|
||||||
|
Fetch metadata from URL and extract title for task name.
|
||||||
|
|
||||||
|
Also converts YouTube @handle URLs to channel IDs when possible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to fetch metadata from
|
||||||
|
preset: Preset name to use for extraction options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (title or None, converted_url or None)
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.features.ytdlp.extractor import fetch_info
|
||||||
|
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
|
||||||
|
|
||||||
|
params = YTDLPOpts.get_instance().add_cli("-I0", from_user=False)
|
||||||
|
if preset:
|
||||||
|
params.preset(name=preset)
|
||||||
|
|
||||||
|
(metadata, _) = await fetch_info(config=params.get_all(), url=url)
|
||||||
|
|
||||||
|
if not metadata or not isinstance(metadata, dict):
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
title = ag(metadata, ["title", "fulltitle"])
|
||||||
|
name = str(title)[:255] if title else None
|
||||||
|
|
||||||
|
converted_url: str | None = None
|
||||||
|
channel_id = metadata.get("channel_id")
|
||||||
|
if channel_id and "/@" in url:
|
||||||
|
import re
|
||||||
|
|
||||||
|
converted_url = re.sub(r"/@[^/]+", f"/channel/{channel_id}", url)
|
||||||
|
|
||||||
|
return (name, converted_url)
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.debug(f"Timeout while inferring name from '{url}'")
|
||||||
|
return (None, None)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.debug(f"Failed to infer name from '{url}': {e}")
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
def _model(model: Any) -> Task:
|
def _model(model: Any) -> Task:
|
||||||
return Task.model_validate(model)
|
return Task.model_validate(model)
|
||||||
|
|
@ -53,35 +169,107 @@ async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder)
|
||||||
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
data = [data]
|
||||||
|
|
||||||
|
if not isinstance(data, list) or len(data) == 0:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Invalid request body expecting dict."},
|
{"error": "Invalid request body. Expecting dict or list of dicts."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_item = data[0]
|
||||||
|
if not isinstance(first_item, dict) or not first_item.get("url"):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "First item requires 'url' field."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not first_item.get("name"):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "First item requires 'name' field."},
|
||||||
status=web.HTTPBadRequest.status_code,
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item: Task = Task.model_validate(data)
|
first_task: Task = Task.model_validate(first_item)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
data={"error": "Failed to validate first task.", "detail": format_validation_errors(exc)},
|
||||||
status=web.HTTPBadRequest.status_code,
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
if await repo.get_by_name(item.name):
|
if await repo.get_by_name(first_task.name):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"error": f"Task with name '{item.name}' already exists."},
|
data={"error": f"Task with name '{first_task.name}' already exists."},
|
||||||
status=web.HTTPConflict.status_code,
|
status=web.HTTPConflict.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
created_tasks: list[dict[str, Any]] = []
|
||||||
created = await repo.create(item.model_dump())
|
base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"})
|
||||||
saved = _serialize(created)
|
|
||||||
except ValueError as exc:
|
|
||||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved))
|
for idx, item in enumerate(data):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
LOG.warning(f"Skipping item {idx}: not a dict")
|
||||||
|
continue
|
||||||
|
|
||||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
url = str(item.get("url", "")).strip()
|
||||||
|
if not url:
|
||||||
|
LOG.debug(f"Skipping item {idx}: empty URL")
|
||||||
|
continue
|
||||||
|
|
||||||
|
inferred_name, converted_url = await _get_info(url, item.get("preset", first_task.preset))
|
||||||
|
name = item.get("name", first_task.name if 0 == idx else (inferred_name or f"task-{idx}"))
|
||||||
|
|
||||||
|
final_name = str(name)
|
||||||
|
counter = 1
|
||||||
|
while await repo.get_by_name(final_name):
|
||||||
|
final_name = f"{name}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
item_settings = base_settings.copy()
|
||||||
|
for key in ["timer", "preset", "folder", "template", "cli", "auto_start", "handler_enabled", "enabled"]:
|
||||||
|
if key in item and item[key] is not None:
|
||||||
|
item_settings[key] = item[key]
|
||||||
|
|
||||||
|
task_dict: dict[str, Any] = {
|
||||||
|
"name": final_name,
|
||||||
|
"url": converted_url or url,
|
||||||
|
**item_settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not task_dict.get("timer") and first_task.timer and idx > 0:
|
||||||
|
task_dict["timer"] = _offset_timer(first_task.timer, idx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated = Task.model_validate(task_dict)
|
||||||
|
task_data = validated.model_dump()
|
||||||
|
except ValidationError as exc:
|
||||||
|
LOG.warning(f"Skipping task {idx}: validation failed - {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await repo.create(task_data)
|
||||||
|
saved = _serialize(created)
|
||||||
|
created_tasks.append(saved)
|
||||||
|
notify.emit(
|
||||||
|
Events.CONFIG_UPDATE,
|
||||||
|
data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
LOG.warning(f"Failed to create task {idx}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(created_tasks) == 0:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Failed to create any tasks."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(created_tasks) == 1:
|
||||||
|
return web.json_response(data=created_tasks[0], status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
return web.json_response(data=created_tasks, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
|
||||||
@route("GET", r"api/tasks/{id:\d+}", "tasks_get")
|
@route("GET", r"api/tasks/{id:\d+}", "tasks_get")
|
||||||
|
|
|
||||||
|
|
@ -220,10 +220,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, toRef, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useStorage, useIntersectionObserver } from '@vueuse/core'
|
import { useStorage, useIntersectionObserver } from '@vueuse/core'
|
||||||
import type { item_request } from '~/types/item'
|
import type { item_request } from '~/types/item'
|
||||||
|
import type { Preset } from '~/types/presets'
|
||||||
import type { ItemStatus, StoreItem } from '~/types/store'
|
import type { ItemStatus, StoreItem } from '~/types/store'
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification'
|
||||||
import { useConfigStore } from '~/stores/ConfigStore'
|
import { useConfigStore } from '~/stores/ConfigStore'
|
||||||
|
|
@ -250,7 +251,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
|
||||||
const { app, paused, presets } = storeToRefs(configStore)
|
const app = toRef(configStore, 'app')
|
||||||
|
const paused = toRef(configStore, 'paused')
|
||||||
|
const presets = toRef(configStore, 'presets')
|
||||||
const { queue, history } = storeToRefs(stateStore)
|
const { queue, history } = storeToRefs(stateStore)
|
||||||
|
|
||||||
const embedUrl = ref<string>('')
|
const embedUrl = ref<string>('')
|
||||||
|
|
@ -662,7 +665,7 @@ const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
|
||||||
toast.info('Removed from history queue.')
|
toast.info('Removed from history queue.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter_presets = (flag: boolean = true) => presets.value.filter(item => item.default === flag)
|
const filter_presets = (flag: boolean = true) => presets.value.filter((item: Preset) => item.default === flag)
|
||||||
|
|
||||||
const showMessage = (item: StoreItem) => {
|
const showMessage = (item: StoreItem) => {
|
||||||
if (!item?.msg || item.msg === item?.error) {
|
if (!item?.msg || item.msg === item?.error) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="columns mt-2 is-multiline">
|
<main class="columns mt-2 is-multiline">
|
||||||
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)">
|
<div class="column is-12" v-if="!isMultiLineInput && form?.url && is_yt_handle(form.url)">
|
||||||
<Message title="Information" class="is-info" icon="fas fa-info-circle">
|
<Message title="Information" class="is-info" icon="fas fa-info-circle">
|
||||||
You are using a YouTube link with <code>@handle</code> instead of <code>channel_id</code>. To activate RSS
|
You are using a YouTube link with <code>@handle</code> instead of <code>channel_id</code>. To activate RSS
|
||||||
feed support for URL click on the <b>Convert URL</b> link.
|
feed support for URL click on the <b>Convert URL</b> link.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12" v-if="form?.url && is_generic_rss(form.url)">
|
<div class="column is-12" v-if="form?.url && is_generic_rss(form.url) && !isMultiLineInput">
|
||||||
<Message title="Information" class="is-warning" icon="fas fa-info-circle">
|
<Message title="Information" class="is-warning" icon="fas fa-info-circle">
|
||||||
You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found
|
You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found
|
||||||
in this feed.
|
in this feed.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="column is-12" v-if="isMultiLineInput">
|
||||||
|
<Message title="Multiple URLs" class="is-info" icon="fas fa-info-circle">
|
||||||
|
<ul>
|
||||||
|
<li>First URL uses the <b>Name</b> provided above with full settings.</li>
|
||||||
|
<li>Other URLs infer names from metadata and inherit settings from the first URL.</li>
|
||||||
|
<li v-if="form.timer">Timers are offset by 5-minute increments for each URL.</li>
|
||||||
|
</ul>
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -83,21 +92,30 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="url">
|
<label class="label is-inline" for="url">
|
||||||
<span class="icon"><i class="fa-solid fa-link" /></span>
|
<span class="icon"><i class="fa-solid fa-link" /></span>
|
||||||
URL
|
<span>URL</span>
|
||||||
<template v-if="is_yt_handle(form.url)">
|
<span class="tag is-info is-light is-small ml-2" v-if="urlCount > 1">{{ urlCount }} URLs</span>
|
||||||
|
<template v-if="!isMultiLineInput && is_yt_handle(form.url)">
|
||||||
- <NuxtLink @click="async () => form.url = await convert_url(form.url)">Convert URL</NuxtLink>
|
- <NuxtLink @click="async () => form.url = await convert_url(form.url)">Convert URL</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="url" class="input" id="url" v-model="form.url"
|
<textarea v-if="isMultiLineInput" ref="urlTextarea" class="input" id="url"
|
||||||
|
:disabled="addInProgress || convertInProgress" v-model="form.url" @keydown="handleKeyDown"
|
||||||
|
@input="adjustTextareaHeight"
|
||||||
|
style="resize: none; overflow-y: auto; min-height: 38px; max-height: 300px;"
|
||||||
|
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg" />
|
||||||
|
<input v-else type="url" class="input" id="url" v-model="form.url"
|
||||||
:disabled="addInProgress || convertInProgress"
|
:disabled="addInProgress || convertInProgress"
|
||||||
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg">
|
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg" @keydown="handleKeyDown"
|
||||||
|
@paste="handlePaste">
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-link"
|
<span class="icon is-small is-left"><i class="fa-solid fa-link"
|
||||||
:class="{ 'fa-spin': convertInProgress }" /></span>
|
:class="{ 'fa-spin': convertInProgress }" /></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 class="is-bold">The channel or playlist URL.</span>
|
<span class="is-bold">
|
||||||
|
The channel or playlist URL. Use Shift+Enter for multiple URLs.
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -366,6 +384,7 @@ import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
import type { ExportedTask, Task } from '~/types/tasks'
|
import type { ExportedTask, Task } from '~/types/tasks'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
import { shortPath } from "~/utils"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reference?: number | null | undefined
|
reference?: number | null | undefined
|
||||||
|
|
@ -375,7 +394,7 @@ const props = defineProps<{
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
(e: 'submit', payload: { reference: number | null | undefined, task: Task, archive_all?: boolean }): void
|
(e: 'submit', payload: { reference: number | null | undefined, task: Task | Task[], archive_all?: boolean }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -388,11 +407,86 @@ const import_string = ref<string>('')
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
const archiveAllAfterAdd = ref<boolean>(false)
|
const archiveAllAfterAdd = ref<boolean>(false)
|
||||||
|
const urlTextarea = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
||||||
const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
|
const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
|
||||||
const form = reactive<Task>({ ...props.task })
|
const form = reactive<Task>({ ...props.task })
|
||||||
|
|
||||||
|
const isMultiLineInput = computed(() => !!form.url && form.url.includes('\n'))
|
||||||
|
const urlCount = computed(() => splitUrls(form.url || '').length)
|
||||||
|
|
||||||
|
const splitUrls = (urlString: string): string[] => {
|
||||||
|
return urlString
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustTextareaHeight = async (): Promise<void> => {
|
||||||
|
await nextTick()
|
||||||
|
if (urlTextarea.value) {
|
||||||
|
urlTextarea.value.style.height = 'auto'
|
||||||
|
const newHeight = Math.min(urlTextarea.value.scrollHeight, 300)
|
||||||
|
urlTextarea.value.style.height = `${newHeight}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||||
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||||
|
const isTextarea = target.tagName === 'TEXTAREA'
|
||||||
|
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
|
||||||
|
if (event.ctrlKey && isTextarea) {
|
||||||
|
event.preventDefault()
|
||||||
|
checkInfo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey && !isTextarea) {
|
||||||
|
event.preventDefault()
|
||||||
|
const cursorPos = target.selectionStart || form.url.length
|
||||||
|
form.url = form.url.substring(0, cursorPos) + '\n' + form.url.substring(target.selectionEnd || cursorPos)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (urlTextarea.value) {
|
||||||
|
await adjustTextareaHeight()
|
||||||
|
urlTextarea.value.setSelectionRange(cursorPos + 1, cursorPos + 1)
|
||||||
|
urlTextarea.value.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
||||||
|
const pastedText = event.clipboardData?.getData('text') || ''
|
||||||
|
if (!pastedText.includes('\n')) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const currentValue = form.url || ''
|
||||||
|
const start = target.selectionStart || currentValue.length
|
||||||
|
const end = target.selectionEnd || currentValue.length
|
||||||
|
form.url = currentValue.substring(0, start) + pastedText + currentValue.substring(end)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (urlTextarea.value) {
|
||||||
|
await adjustTextareaHeight()
|
||||||
|
const newPos = start + pastedText.length
|
||||||
|
urlTextarea.value.setSelectionRange(newPos, newPos)
|
||||||
|
urlTextarea.value.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isMultiLineInput, async (newValue) => {
|
||||||
|
await nextTick()
|
||||||
|
if (newValue) {
|
||||||
|
await adjustTextareaHeight()
|
||||||
|
urlTextarea.value?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
.filter(opt => !opt.ignored)
|
.filter(opt => !opt.ignored)
|
||||||
.flatMap(opt => opt.flags
|
.flatMap(opt => opt.flags
|
||||||
|
|
@ -421,12 +515,16 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkInfo = async (): Promise<void> => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
const required = ['name', 'url'] as const
|
const urls = splitUrls(form.url || '')
|
||||||
for (const key of required) {
|
|
||||||
if (!form[key]) {
|
if (urls.length === 0) {
|
||||||
toast.error(`The ${key} field is required.`)
|
toast.error('At least one URL is required.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.name) {
|
||||||
|
toast.error('The name field is required.')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.folder) {
|
if (form.folder) {
|
||||||
|
|
@ -445,7 +543,7 @@ const checkInfo = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(form.url)
|
new URL(urls[0] || '')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Invalid URL')
|
toast.error('Invalid URL')
|
||||||
return
|
return
|
||||||
|
|
@ -457,7 +555,30 @@ const checkInfo = async (): Promise<void> => {
|
||||||
form.cli = form.cli.trim()
|
form.cli = form.cli.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form), archive_all: archiveAllAfterAdd.value })
|
if (urls.length === 1) {
|
||||||
|
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form), archive_all: archiveAllAfterAdd.value })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: Task[] = urls.map((url, idx) => {
|
||||||
|
if (idx === 0) {
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
url: url,
|
||||||
|
folder: form.folder,
|
||||||
|
preset: form.preset,
|
||||||
|
timer: form.timer,
|
||||||
|
template: form.template,
|
||||||
|
cli: form.cli,
|
||||||
|
auto_start: form.auto_start,
|
||||||
|
handler_enabled: form.handler_enabled,
|
||||||
|
enabled: form.enabled,
|
||||||
|
} as Task
|
||||||
|
}
|
||||||
|
return { url } as Task
|
||||||
|
})
|
||||||
|
|
||||||
|
emitter('submit', { reference: toRaw(props.reference), task: tasks, archive_all: archiveAllAfterAdd.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const importItem = async (): Promise<void> => {
|
const importItem = async (): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue'
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification'
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -34,6 +33,10 @@ const isLoading = ref<boolean>(false)
|
||||||
* Indicates if an add/update operation is in progress.
|
* Indicates if an add/update operation is in progress.
|
||||||
*/
|
*/
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false)
|
||||||
|
/**
|
||||||
|
* Set of task IDs that are currently in progress.
|
||||||
|
*/
|
||||||
|
const inProgressIds = ref<Set<number>>(new Set())
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
|
|
@ -181,14 +184,14 @@ const getTask = async (id: number): Promise<Task | null> => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new task via API.
|
* Creates a new task via API.
|
||||||
* @param task Task to create
|
* @param task Task to create (single task or array for batch)
|
||||||
* @param callback Optional callback with APIResponse result
|
* @param callback Optional callback with APIResponse result
|
||||||
* @returns Created task or null on error
|
* @returns Created task(s) or null on error
|
||||||
*/
|
*/
|
||||||
const createTask = async (
|
const createTask = async (
|
||||||
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
task: Omit<Task, 'id' | 'created_at' | 'updated_at'> | Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
|
||||||
callback?: (response: APIResponse<Task>) => void,
|
callback?: (response: APIResponse<Task | Task[]>) => void,
|
||||||
): Promise<Task | null> => {
|
): Promise<Task | Task[] | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/tasks/', {
|
const response = await request('/api/tasks/', {
|
||||||
|
|
@ -198,7 +201,19 @@ const createTask = async (
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response)
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
const created = await parse_api_response<Task>(json)
|
const created = await parse_api_response<Task | Array<Task>>(json)
|
||||||
|
|
||||||
|
if (Array.isArray(created)) {
|
||||||
|
notify.success(`${created.length} tasks created.`)
|
||||||
|
created.forEach(t => updateTasksList(t))
|
||||||
|
lastError.value = null
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: true, error: null, detail: null, data: created })
|
||||||
|
}
|
||||||
|
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
updateTasksList(created)
|
updateTasksList(created)
|
||||||
notify.success('Task created.')
|
notify.success('Task created.')
|
||||||
|
|
@ -242,7 +257,7 @@ const updateTask = async (
|
||||||
try {
|
try {
|
||||||
// Explicitly remove id, created_at, updated_at fields if present
|
// Explicitly remove id, created_at, updated_at fields if present
|
||||||
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
|
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
|
||||||
|
|
||||||
const response = await request(`/api/tasks/${id}`, {
|
const response = await request(`/api/tasks/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(taskData),
|
body: JSON.stringify(taskData),
|
||||||
|
|
@ -465,6 +480,29 @@ const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse |
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => lastError.value = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a task is currently in progress.
|
||||||
|
* @param id Task ID
|
||||||
|
* @returns true if the task is in progress
|
||||||
|
*/
|
||||||
|
const isTaskInProgress = (id: number): boolean => inProgressIds.value.has(id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a task as in progress.
|
||||||
|
* @param id Task ID
|
||||||
|
*/
|
||||||
|
const setTaskInProgress = (id: number): void => {
|
||||||
|
inProgressIds.value.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the in progress state for a task.
|
||||||
|
* @param id Task ID
|
||||||
|
*/
|
||||||
|
const clearTaskInProgress = (id: number): void => {
|
||||||
|
inProgressIds.value.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets all state to initial values (for testing only).
|
* Resets all state to initial values (for testing only).
|
||||||
*/
|
*/
|
||||||
|
|
@ -482,6 +520,7 @@ const __resetForTesting = () => {
|
||||||
addInProgress.value = false
|
addInProgress.value = false
|
||||||
lastError.value = null
|
lastError.value = null
|
||||||
throwInstead.value = false
|
throwInstead.value = false
|
||||||
|
inProgressIds.value = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -496,6 +535,10 @@ export const useTasks = () => ({
|
||||||
isLoading: readonly(isLoading),
|
isLoading: readonly(isLoading),
|
||||||
addInProgress: readonly(addInProgress),
|
addInProgress: readonly(addInProgress),
|
||||||
lastError: readonly(lastError),
|
lastError: readonly(lastError),
|
||||||
|
inProgressIds: readonly(inProgressIds),
|
||||||
|
isTaskInProgress,
|
||||||
|
setTaskInProgress,
|
||||||
|
clearTaskInProgress,
|
||||||
loadTasks,
|
loadTasks,
|
||||||
getTask,
|
getTask,
|
||||||
createTask,
|
createTask,
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@
|
||||||
<NuxtLink target="_blank" :href="item.url">
|
<NuxtLink target="_blank" :href="item.url">
|
||||||
{{ remove_tags(item.name) }}
|
{{ remove_tags(item.name) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="icon" v-if="item.in_progress">
|
<span class="icon" v-if="isTaskInProgress(item.id!)">
|
||||||
<i class="fa-solid fa-spinner fa-spin has-text-info" />
|
<i class="fa-solid fa-spinner fa-spin has-text-info" />
|
||||||
</span>
|
</span>
|
||||||
<span class="tag is-danger is-small ml-1" v-if="!item.enabled">Disabled</span>
|
<span class="tag is-danger is-small ml-1" v-if="!item.enabled">Disabled</span>
|
||||||
|
|
@ -468,8 +468,6 @@ import { sleep } from '~/utils'
|
||||||
import { useSessionCache } from '~/utils/cache'
|
import { useSessionCache } from '~/utils/cache'
|
||||||
import type { item_request } from '~/types/item'
|
import type { item_request } from '~/types/item'
|
||||||
|
|
||||||
type TaskWithUI = Task & { in_progress?: boolean }
|
|
||||||
|
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
|
|
@ -481,9 +479,9 @@ const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
|
||||||
const tasksComposable = useTasks()
|
const tasksComposable = useTasks()
|
||||||
const { tasks, isLoading, addInProgress } = tasksComposable
|
const { tasks, isLoading, addInProgress, isTaskInProgress, setTaskInProgress, clearTaskInProgress } = tasksComposable
|
||||||
|
|
||||||
const task = ref<Partial<TaskWithUI>>({})
|
const task = ref<Partial<Task>>({})
|
||||||
const taskRef = ref<number | null>(null)
|
const taskRef = ref<number | null>(null)
|
||||||
const toggleForm = ref<boolean>(false)
|
const toggleForm = ref<boolean>(false)
|
||||||
const selectedElms = ref<Array<number>>([])
|
const selectedElms = ref<Array<number>>([])
|
||||||
|
|
@ -491,7 +489,7 @@ const masterSelectAll = ref(false)
|
||||||
const massRun = ref<boolean>(false)
|
const massRun = ref<boolean>(false)
|
||||||
const massDelete = ref<boolean>(false)
|
const massDelete = ref<boolean>(false)
|
||||||
const table_container = ref(false)
|
const table_container = ref(false)
|
||||||
const inspectTask = ref<TaskWithUI | null>(null)
|
const inspectTask = ref<Task | null>(null)
|
||||||
const query = ref()
|
const query = ref()
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false)
|
||||||
const CACHE_KEY = 'tasks:handler_support'
|
const CACHE_KEY = 'tasks:handler_support'
|
||||||
|
|
@ -591,7 +589,7 @@ const filteredTasks = computed(() => {
|
||||||
if (!q) return tasks.value;
|
if (!q) return tasks.value;
|
||||||
|
|
||||||
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
|
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
|
||||||
}) as ComputedRef<Array<TaskWithUI>>;
|
}) as ComputedRef<Array<Task>>;
|
||||||
|
|
||||||
const reloadContent = async (fromMounted: boolean = false) => {
|
const reloadContent = async (fromMounted: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -707,11 +705,11 @@ const toggleHandlerEnabled = async (item: Task) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task, archive_all?: boolean }) => {
|
const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task | Task[], archive_all?: boolean }) => {
|
||||||
let createdOrUpdated: Task | null = null
|
let createdOrUpdated: Task | Task[] | null = null
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
createdOrUpdated = await tasksComposable.updateTask(reference, task)
|
createdOrUpdated = await tasksComposable.updateTask(reference, task as Task)
|
||||||
} else {
|
} else {
|
||||||
createdOrUpdated = await tasksComposable.createTask(task)
|
createdOrUpdated = await tasksComposable.createTask(task)
|
||||||
}
|
}
|
||||||
|
|
@ -720,15 +718,23 @@ const updateItem = async ({ reference, task, archive_all }: { reference?: number
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await checkHandlerSupport(createdOrUpdated)
|
const tasksList = Array.isArray(createdOrUpdated) ? createdOrUpdated : [createdOrUpdated]
|
||||||
|
|
||||||
if (!reference && true === archive_all && createdOrUpdated.id) {
|
|
||||||
await sleep(1)
|
|
||||||
await nextTick()
|
|
||||||
await archiveAll(createdOrUpdated, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm(true)
|
resetForm(true)
|
||||||
|
|
||||||
|
if (!reference && true === archive_all) {
|
||||||
|
await nextTick()
|
||||||
|
await sleep(1)
|
||||||
|
toast.info(`Archiving existing items for '${tasksList.length}' tasks. This will take a while...`)
|
||||||
|
for (const t of tasksList) {
|
||||||
|
if (t.id) await archiveAll(t, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of tasksList) {
|
||||||
|
await checkHandlerSupport(t)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editItem = (item: Task) => {
|
const editItem = (item: Task) => {
|
||||||
|
|
@ -798,13 +804,15 @@ const runSelected = async () => {
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runNow = async (item: TaskWithUI, mass: boolean = false) => {
|
const runNow = async (item: Task, mass: boolean = false) => {
|
||||||
|
if (!item.id) return
|
||||||
|
|
||||||
if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) {
|
if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false === mass) {
|
if (false === mass) {
|
||||||
item.in_progress = true
|
setTaskInProgress(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: item_request = {
|
const data: item_request = {
|
||||||
|
|
@ -841,7 +849,7 @@ const runNow = async (item: TaskWithUI, mass: boolean = false) => {
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
item.in_progress = false
|
if (item.id) clearTaskInProgress(item.id)
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -892,7 +900,7 @@ const get_tags = (name: string): Array<string> => {
|
||||||
|
|
||||||
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
||||||
|
|
||||||
const archiveAll = async (item: TaskWithUI, by_pass: boolean = false) => {
|
const archiveAll = async (item: Task, by_pass: boolean = false) => {
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
toast.error('Task ID is missing')
|
toast.error('Task ID is missing')
|
||||||
return
|
return
|
||||||
|
|
@ -909,17 +917,17 @@ const archiveAll = async (item: TaskWithUI, by_pass: boolean = false) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item.in_progress = true
|
setTaskInProgress(item.id)
|
||||||
await tasksComposable.markTaskItems(item.id)
|
await tasksComposable.markTaskItems(item.id)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
|
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
item.in_progress = false
|
clearTaskInProgress(item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unarchiveAll = async (item: TaskWithUI) => {
|
const unarchiveAll = async (item: Task) => {
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
toast.error('Task ID is missing')
|
toast.error('Task ID is missing')
|
||||||
return
|
return
|
||||||
|
|
@ -934,17 +942,17 @@ const unarchiveAll = async (item: TaskWithUI) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.in_progress = true
|
setTaskInProgress(item.id)
|
||||||
await tasksComposable.unmarkTaskItems(item.id)
|
await tasksComposable.unmarkTaskItems(item.id)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
item.in_progress = false
|
if (item.id) clearTaskInProgress(item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateMeta = async (item: TaskWithUI) => {
|
const generateMeta = async (item: Task) => {
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
toast.error('Task ID is missing')
|
toast.error('Task ID is missing')
|
||||||
return
|
return
|
||||||
|
|
@ -977,13 +985,13 @@ const generateMeta = async (item: TaskWithUI) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.in_progress = true
|
setTaskInProgress(item.id)
|
||||||
await tasksComposable.generateTaskMetadata(item.id)
|
await tasksComposable.generateTaskMetadata(item.id)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
item.in_progress = false
|
if (item.id) clearTaskInProgress(item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import path from "path";
|
import { defineNuxtConfig } from 'nuxt/config'
|
||||||
|
|
||||||
let extraNitro = {}
|
let extraNitro = {}
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,17 +63,15 @@ export default defineNuxtConfig({
|
||||||
linkActiveClass: "is-selected",
|
linkActiveClass: "is-selected",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
'floating-vue/nuxt',
|
'floating-vue/nuxt',
|
||||||
'development' === process.env.NODE_ENV ? '@nuxt/eslint' : '',
|
'development' === process.env.NODE_ENV ? '@nuxt/eslint' : '',
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
output: {
|
output: {
|
||||||
publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist')
|
publicDir: 'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
|
||||||
},
|
},
|
||||||
...extraNitro,
|
...extraNitro,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue