feat: improve task creation to support multiple URLs.

This commit is contained in:
arabcoders 2026-02-17 18:51:04 +03:00
parent 5e40e2f610
commit f166c418f8
7 changed files with 472 additions and 86 deletions

51
API.md
View file

@ -938,43 +938,68 @@ Notes:
---
### 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
{
"name": "My Task",
"url": "https://youtube.com/...",
"timer": "5 */2 * * *",
"cookies": "",
"config": {},
"template": "...",
"folder": "...",
"preset": "...",
"preset": "default",
"folder": "",
"template": "",
"cli": "",
"auto_start": true,
"handler_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
{
"id": 1,
"name": "My Task",
"url": "https://youtube.com/...",
"timer": "5 */2 * * *",
"cookies": "",
"config": {},
"template": "...",
"folder": "...",
"preset": "...",
"preset": "default",
"folder": "",
"template": "",
"cli": "",
"auto_start": true,
"handler_enabled": true,
"enabled": true
}
```
**Response (multiple tasks):**
```json
[
{"id": 1, "name": "First Task", "url": "https://...", ...},
...
]
```
**Error Responses**:
- `400 Bad Request` - Invalid request body or validation error
- `409 Conflict` - Task with the same name already exists

View file

@ -26,6 +26,122 @@ if TYPE_CHECKING:
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:
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:
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(
{"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,
)
try:
item: Task = Task.model_validate(data)
first_task: Task = Task.model_validate(first_item)
except ValidationError as exc:
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,
)
if await repo.get_by_name(item.name):
if await repo.get_by_name(first_task.name):
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,
)
try:
created = await repo.create(item.model_dump())
saved = _serialize(created)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
created_tasks: list[dict[str, Any]] = []
base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"})
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")

View file

@ -220,10 +220,11 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, toRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useStorage, useIntersectionObserver } from '@vueuse/core'
import type { item_request } from '~/types/item'
import type { Preset } from '~/types/presets'
import type { ItemStatus, StoreItem } from '~/types/store'
import { useNotification } from '~/composables/useNotification'
import { useConfigStore } from '~/stores/ConfigStore'
@ -250,7 +251,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const isMobile = useMediaQuery({ maxWidth: 1024 })
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 embedUrl = ref<string>('')
@ -662,7 +665,7 @@ const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
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) => {
if (!item?.msg || item.msg === item?.error) {

View file

@ -1,17 +1,26 @@
<template>
<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">
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.
</Message>
</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">
You are using a generic RSS/Atom feed URL. The task handler will automatically download new items found
in this feed.
</Message>
</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">
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card">
@ -83,21 +92,30 @@
<div class="field">
<label class="label is-inline" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span>
URL
<template v-if="is_yt_handle(form.url)">
<span>URL</span>
<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>
</template>
</label>
<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"
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"
:class="{ 'fa-spin': convertInProgress }" /></span>
</div>
<span class="help">
<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>
</div>
</div>
@ -366,6 +384,7 @@ import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete'
import type { ExportedTask, Task } from '~/types/tasks'
import { useConfirm } from '~/composables/useConfirm'
import { shortPath } from "~/utils"
const props = defineProps<{
reference?: number | null | undefined
@ -375,7 +394,7 @@ const props = defineProps<{
const emitter = defineEmits<{
(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()
@ -388,11 +407,86 @@ const import_string = ref<string>('')
const showOptions = ref<boolean>(false)
const ytDlpOpt = ref<AutoCompleteOptions>([])
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 GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
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
.filter(opt => !opt.ignored)
.flatMap(opt => opt.flags
@ -421,12 +515,16 @@ onMounted(() => {
})
const checkInfo = async (): Promise<void> => {
const required = ['name', 'url'] as const
for (const key of required) {
if (!form[key]) {
toast.error(`The ${key} field is required.`)
return
}
const urls = splitUrls(form.url || '')
if (urls.length === 0) {
toast.error('At least one URL is required.')
return
}
if (!form.name) {
toast.error('The name field is required.')
return
}
if (form.folder) {
@ -445,7 +543,7 @@ const checkInfo = async (): Promise<void> => {
}
try {
new URL(form.url)
new URL(urls[0] || '')
} catch {
toast.error('Invalid URL')
return
@ -457,7 +555,30 @@ const checkInfo = async (): Promise<void> => {
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> => {

View file

@ -1,5 +1,4 @@
import { ref, readonly } from 'vue'
import { useNotification } from '~/composables/useNotification'
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
import type {
@ -34,6 +33,10 @@ const isLoading = ref<boolean>(false)
* Indicates if an add/update operation is in progress.
*/
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.
*/
@ -181,14 +184,14 @@ const getTask = async (id: number): Promise<Task | null> => {
/**
* 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
* @returns Created task or null on error
* @returns Created task(s) or null on error
*/
const createTask = async (
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
callback?: (response: APIResponse<Task>) => void,
): Promise<Task | null> => {
task: Omit<Task, 'id' | 'created_at' | 'updated_at'> | Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
callback?: (response: APIResponse<Task | Task[]>) => void,
): Promise<Task | Task[] | null> => {
addInProgress.value = true
try {
const response = await request('/api/tasks/', {
@ -198,7 +201,19 @@ const createTask = async (
await ensureSuccess(response)
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)
notify.success('Task created.')
@ -242,7 +257,7 @@ const updateTask = async (
try {
// Explicitly remove id, created_at, updated_at fields if present
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
const response = await request(`/api/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(taskData),
@ -465,6 +480,29 @@ const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse |
*/
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).
*/
@ -482,6 +520,7 @@ const __resetForTesting = () => {
addInProgress.value = false
lastError.value = null
throwInstead.value = false
inProgressIds.value = new Set()
}
/**
@ -496,6 +535,10 @@ export const useTasks = () => ({
isLoading: readonly(isLoading),
addInProgress: readonly(addInProgress),
lastError: readonly(lastError),
inProgressIds: readonly(inProgressIds),
isTaskInProgress,
setTaskInProgress,
clearTaskInProgress,
loadTasks,
getTask,
createTask,

View file

@ -268,7 +268,7 @@
<NuxtLink target="_blank" :href="item.url">
{{ remove_tags(item.name) }}
</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" />
</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 type { item_request } from '~/types/item'
type TaskWithUI = Task & { in_progress?: boolean }
const box = useConfirm()
const toast = useNotification()
const config = useConfigStore()
@ -481,9 +479,9 @@ const display_style = useStorage<string>("tasks_display_style", "cards")
const isMobile = useMediaQuery({ maxWidth: 1024 })
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 toggleForm = ref<boolean>(false)
const selectedElms = ref<Array<number>>([])
@ -491,7 +489,7 @@ const masterSelectAll = ref(false)
const massRun = ref<boolean>(false)
const massDelete = ref<boolean>(false)
const table_container = ref(false)
const inspectTask = ref<TaskWithUI | null>(null)
const inspectTask = ref<Task | null>(null)
const query = ref()
const toggleFilter = ref(false)
const CACHE_KEY = 'tasks:handler_support'
@ -591,7 +589,7 @@ const filteredTasks = computed(() => {
if (!q) return tasks.value;
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
}) as ComputedRef<Array<TaskWithUI>>;
}) as ComputedRef<Array<Task>>;
const reloadContent = async (fromMounted: boolean = false) => {
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 }) => {
let createdOrUpdated: Task | null = null
const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task | Task[], archive_all?: boolean }) => {
let createdOrUpdated: Task | Task[] | null = null
if (reference) {
createdOrUpdated = await tasksComposable.updateTask(reference, task)
createdOrUpdated = await tasksComposable.updateTask(reference, task as Task)
} else {
createdOrUpdated = await tasksComposable.createTask(task)
}
@ -720,15 +718,23 @@ const updateItem = async ({ reference, task, archive_all }: { reference?: number
return
}
await checkHandlerSupport(createdOrUpdated)
if (!reference && true === archive_all && createdOrUpdated.id) {
await sleep(1)
await nextTick()
await archiveAll(createdOrUpdated, true)
}
const tasksList = Array.isArray(createdOrUpdated) ? createdOrUpdated : [createdOrUpdated]
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) => {
@ -798,13 +804,15 @@ const runSelected = async () => {
}, 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.`))) {
return
}
if (false === mass) {
item.in_progress = true
setTaskInProgress(item.id)
}
const data: item_request = {
@ -841,7 +849,7 @@ const runNow = async (item: TaskWithUI, mass: boolean = false) => {
setTimeout(async () => {
await nextTick()
item.in_progress = false
if (item.id) clearTaskInProgress(item.id)
}, 500)
}
@ -892,7 +900,7 @@ const get_tags = (name: string): Array<string> => {
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) {
toast.error('Task ID is missing')
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)
} catch (e: any) {
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
return
} finally {
item.in_progress = false
clearTaskInProgress(item.id)
}
}
const unarchiveAll = async (item: TaskWithUI) => {
const unarchiveAll = async (item: Task) => {
if (!item.id) {
toast.error('Task ID is missing')
return
@ -934,17 +942,17 @@ const unarchiveAll = async (item: TaskWithUI) => {
return;
}
item.in_progress = true
setTaskInProgress(item.id)
await tasksComposable.unmarkTaskItems(item.id)
} catch (e: any) {
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
return
} finally {
item.in_progress = false
if (item.id) clearTaskInProgress(item.id)
}
}
const generateMeta = async (item: TaskWithUI) => {
const generateMeta = async (item: Task) => {
if (!item.id) {
toast.error('Task ID is missing')
return
@ -977,13 +985,13 @@ const generateMeta = async (item: TaskWithUI) => {
return;
}
item.in_progress = true
setTaskInProgress(item.id)
await tasksComposable.generateTaskMetadata(item.id)
} catch (e: any) {
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
return
} finally {
item.in_progress = false
if (item.id) clearTaskInProgress(item.id)
}
}
</script>

View file

@ -1,4 +1,4 @@
import path from "path";
import { defineNuxtConfig } from 'nuxt/config'
let extraNitro = {}
try {
@ -63,17 +63,15 @@ export default defineNuxtConfig({
linkActiveClass: "is-selected",
}
},
modules: [
'@pinia/nuxt',
'@vueuse/nuxt',
'floating-vue/nuxt',
'development' === process.env.NODE_ENV ? '@nuxt/eslint' : '',
].filter(Boolean),
nitro: {
output: {
publicDir: path.join(__dirname, 'production' === process.env.NODE_ENV ? 'exported' : 'dist')
publicDir: 'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
},
...extraNitro,
},