ytptube/ui/app/components/TaskDefinitionEditor.vue
2026-06-21 21:25:47 +03:00

1209 lines
36 KiB
Vue

<template>
<div class="space-y-6">
<div class="flex flex-wrap items-center gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
size="sm"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:disabled="isBusy"
@click="showImport = !showImport"
>
{{ showImport ? 'Hide import' : 'Show import' }}
</UButton>
</div>
<div
v-if="showImport"
class="grid gap-4 rounded-lg border border-default bg-muted/10 p-4 lg:grid-cols-2"
>
<UFormField
v-if="availableDefinitions.length"
:ui="fieldUi"
description="Pre-fill from an existing task definition."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-copy" class="size-4 text-toned" />
<span class="font-semibold text-default">Import from existing</span>
</div>
</template>
<USelectMenu
v-model="selectedExistingValue"
:items="existingDefinitionItems"
placeholder="Select a definition"
value-key="value"
label-key="label"
color="neutral"
class="w-full"
:ui="{ content: 'min-w-[13rem]', item: 'pl-6' }"
:search-input="{ placeholder: 'Search definitions' }"
:disabled="isBusy"
@update:model-value="importExisting"
/>
</UFormField>
<UFormField
:ui="fieldUi"
description="Paste shared task definition string here to import it."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-import" class="size-4 text-toned" />
<span class="font-semibold text-default">Import string</span>
</div>
</template>
<div class="flex flex-col gap-2 sm:flex-row">
<UInput
v-model="importString"
type="text"
autocomplete="off"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
<UButton
type="button"
color="neutral"
variant="outline"
icon="i-lucide-import"
class="justify-center sm:min-w-28"
:disabled="isBusy || !importString.trim()"
@click="importFromString"
>
Import
</UButton>
</div>
</UFormField>
</div>
<UAlert
v-if="loading"
color="info"
variant="soft"
icon="i-lucide-loader-circle"
title="Loading"
description="Loading full definition before editing."
/>
<UAlert
v-if="!guiSupported"
color="warning"
variant="soft"
icon="i-lucide-triangle-alert"
title="Advanced mode required"
description="This task definition uses features that cannot be represented with the visual editor. You can still update it via the advanced view."
/>
<UAlert
v-else-if="mode === 'gui'"
color="info"
variant="soft"
icon="i-lucide-info"
title="GUI limitations"
:description="guiLimitations"
/>
<template v-if="mode === 'gui'">
<div class="grid gap-4 md:grid-cols-12">
<UFormField
class="md:col-span-6"
:ui="fieldUi"
description="Human readable label for this definition."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-type" class="size-4 text-toned" />
<span class="font-semibold text-default">Name</span>
</div>
</template>
<UInput
v-model="guiState.name"
type="text"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
<UFormField
class="md:col-span-3"
:ui="fieldUi"
description="Lower values are evaluated first."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-list-ordered" class="size-4 text-toned" />
<span class="font-semibold text-default">Priority</span>
</div>
</template>
<UInput
v-model.number="guiState.priority"
type="number"
min="0"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
<UFormField
class="md:col-span-3"
:ui="fieldUi"
description="Whether this definition is active or not."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-power" class="size-4 text-toned" />
<span class="font-semibold text-default">Status</span>
</div>
</template>
<div
class="flex min-h-11 items-center rounded-md border border-default bg-elevated/40 px-3"
>
<USwitch v-model="guiState.enabled" :disabled="isBusy" />
<span class="ml-3 text-sm text-default">{{
guiState.enabled ? 'Enabled' : 'Disabled'
}}</span>
</div>
</UFormField>
<UFormField class="md:col-span-12" :ui="fieldUi" description="One glob/regex url per line">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-link" class="size-4 text-toned" />
<span class="font-semibold text-default">Match patterns</span>
</div>
</template>
<UTextarea
v-model="guiState.matchText"
:rows="4"
placeholder="https://example.com/*&#10;https://example.org/channel/*"
class="w-full"
:ui="textareaUi"
:disabled="isBusy"
/>
</UFormField>
</div>
<div class="grid gap-5 border-t border-default pt-5 lg:grid-cols-2">
<div class="space-y-4">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-settings-2" class="size-4 text-toned" />
<span>Request setup</span>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<UFormField
:ui="fieldUi"
description="Choose the fetch engine. You should use HTTPX when possible."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-cpu" class="size-4 text-toned" />
<span class="font-semibold text-default">Engine</span>
</div>
</template>
<USelect
v-model="guiState.engineType"
:items="engineItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
<UFormField :ui="fieldUi" description="HTTP method to use when fetching the page.">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-arrow-right-left" class="size-4 text-toned" />
<span class="font-semibold text-default">Request Method</span>
</div>
</template>
<USelect
v-model="guiState.requestMethod"
:items="requestMethodItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
<UFormField
v-if="guiState.engineType === 'selenium'"
class="md:col-span-2"
:ui="fieldUi"
description="Remote webdriver endpoint."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-server" class="size-4 text-toned" />
<span class="font-semibold text-default">Selenium Hub URL (Required)</span>
</div>
</template>
<UInput
v-model="guiState.engineUrl"
type="url"
placeholder="http://selenium:4444/wd/hub"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
<UFormField
class="md:col-span-2"
:ui="fieldUi"
description="Overrides the URL used to fetch the page. Useful for sites with separate feed URLs."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-link" class="size-4 text-toned" />
<span class="font-semibold text-default">Request URL (optional)</span>
</div>
</template>
<UInput
v-model="guiState.requestUrl"
type="url"
placeholder="https://example.com/feed"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
</div>
</div>
<div class="space-y-4">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-list-tree" class="size-4 text-toned" />
<span>Container selector</span>
</div>
</div>
<div class="grid gap-4 md:grid-cols-12">
<UFormField class="md:col-span-4" :ui="fieldUi" description="Selector type">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-shapes" class="size-4 text-toned" />
<span class="font-semibold text-default">Type</span>
</div>
</template>
<USelect
v-model="guiState.containerType"
:items="containerTypeItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
<UFormField class="md:col-span-8" :ui="fieldUi" description="Match expression">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-crosshair" class="size-4 text-toned" />
<span class="font-semibold text-default">Selector / Expression</span>
</div>
</template>
<UInput
v-model="guiState.containerSelector"
type="text"
placeholder="div.card"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</UFormField>
</div>
</div>
</div>
<div class="space-y-4 border-t border-default pt-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
<span>Extracted fields</span>
</div>
</div>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
:disabled="isBusy"
@click="addField"
>
Add field
</UButton>
</div>
<div class="w-full min-w-0 max-w-full overflow-hidden ytp-table-surface">
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 table-fixed w-full text-sm">
<thead class="bg-elevated/60 text-xs uppercase tracking-wide text-toned">
<tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-40">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-key" class="size-3.5 text-toned" />
<span>Key</span>
</span>
</th>
<th class="w-36">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-shapes" class="size-3.5 text-toned" />
<span>Type</span>
</span>
</th>
<th class="w-auto">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-code" class="size-3.5 text-toned" />
<span>Expression</span>
</span>
</th>
<th class="w-44">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-at-sign" class="size-3.5 text-toned" />
<span>Attribute</span>
</span>
</th>
<th class="w-20">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-trash-2" class="size-3.5 text-toned" />
<span>Action</span>
</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr v-if="!guiState.fields.length">
<td colspan="5" class="px-3 py-6 text-center text-sm text-toned">
No extractor fields configured.
</td>
</tr>
<tr
v-for="(field, index) in guiState.fields"
:key="`${index}-${field.key}`"
class="align-top [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3">
<UInput
v-model="field.key"
type="text"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</td>
<td class="px-3 py-3">
<USelect
v-model="field.type"
:items="fieldTypeItems"
value-key="value"
label-key="label"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</td>
<td class="px-3 py-3">
<UInput
v-model="field.expression"
type="text"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</td>
<td class="px-3 py-3">
<UInput
v-model="field.attribute"
type="text"
placeholder="Optional"
class="w-full"
:ui="inputUi"
:disabled="isBusy"
/>
</td>
<td class="px-3 py-3 text-right">
<UButton
type="button"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
square
:disabled="isBusy"
@click="removeField(index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<UAlert
v-if="guiError"
color="error"
variant="soft"
icon="i-lucide-circle-alert"
title="Unable to build definition"
:description="guiError"
/>
</template>
<template v-else>
<UFormField :ui="fieldUi" description="Edit the full task definition JSON directly.">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-file-code-2" class="size-4 text-toned" />
<span class="font-semibold text-default">Raw JSON definition</span>
</div>
</template>
<UTextarea
v-model="jsonText"
:rows="22"
spellcheck="false"
:readonly="submitting"
class="w-full font-mono text-sm"
:ui="advancedTextareaUi"
/>
</UFormField>
<UAlert
v-if="errorMessage"
color="error"
variant="soft"
icon="i-lucide-circle-alert"
title="Invalid JSON"
:description="errorMessage"
/>
</template>
<div
class="flex flex-col gap-3 border-t border-default pt-5 sm:flex-row sm:items-center sm:justify-between"
>
<div class="inline-flex self-start rounded-md border border-default bg-muted/20 p-1">
<UButton
type="button"
size="sm"
icon="i-lucide-sliders-horizontal"
color="neutral"
:variant="mode === 'gui' ? 'soft' : 'ghost'"
:disabled="!guiSupported || isBusy"
@click="switchMode('gui')"
>
GUI
</UButton>
<UButton
type="button"
size="sm"
icon="i-lucide-code"
color="neutral"
:variant="mode === 'advanced' ? 'soft' : 'ghost'"
:disabled="isBusy"
@click="switchMode('advanced')"
>
Advanced
</UButton>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
v-if="mode === 'advanced'"
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-wand-sparkles"
:disabled="isBusy"
class="justify-center"
@click="beautify"
>
Format
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-x"
:disabled="submitting"
class="justify-center"
@click="cancel"
>
Cancel
</UButton>
<UButton
type="button"
color="primary"
size="lg"
icon="i-lucide-save"
:loading="submitting"
:disabled="isBusy"
class="justify-center"
@click="submit"
>
Save
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { prettyName, decode } from '~/utils';
import type { TaskDefinitionDocument, TaskDefinitionSummary } from '~/types/task_definitions';
type EditorMode = 'gui' | 'advanced';
type GuiField = {
key: string;
type: string;
expression: string;
attribute: string;
};
type GuiState = {
name: string;
priority: number;
enabled: boolean;
matchText: string;
engineType: 'httpx' | 'selenium';
engineUrl: string;
requestMethod: string;
requestUrl: string;
containerType: 'css' | 'xpath' | 'jsonpath';
containerSelector: string;
fields: GuiField[];
};
const props = defineProps<{
title?: string;
document: TaskDefinitionDocument | null;
loading?: boolean;
submitting?: boolean;
availableDefinitions?: readonly TaskDefinitionSummary[];
initialShowImport?: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', payload: TaskDefinitionDocument): void;
(e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'import-existing', id: number): void;
}>();
const jsonText = ref('');
const errorMessage = ref<string | null>(null);
const guiError = ref<string | null>(null);
const guiSupported = ref(true);
const mode = ref<EditorMode>('gui');
const showImport = ref(false);
const importString = ref('');
const selectedExisting = ref<number | null>(null);
const selectedExistingValue = computed<number | undefined>({
get: () => selectedExisting.value ?? undefined,
set: (value) => {
selectedExisting.value = value ?? null;
},
});
const availableDefinitions = computed(() => props.availableDefinitions ?? []);
const guiState = reactive<GuiState>({
name: '',
priority: 0,
enabled: true,
matchText: '',
engineType: 'httpx',
engineUrl: '',
requestMethod: 'GET',
requestUrl: '',
containerType: 'css',
containerSelector: '',
fields: [],
});
const loading = computed(() => props.loading ?? false);
const submitting = computed(() => props.submitting ?? false);
const isBusy = computed(() => loading.value || submitting.value);
const guiLimitations =
'Only a single container selector and per-field extractors are exposed. More advanced constructs require raw view mode.';
const fieldUi = {
label: 'font-semibold text-default',
container: 'space-y-2',
description: 'text-sm text-toned',
hint: 'text-sm text-toned',
};
const inputUi = {
root: 'w-full',
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
};
const textareaUi = {
root: 'w-full',
base: 'min-h-[8rem] w-full bg-elevated/60 ring-default focus-visible:ring-primary',
};
const advancedTextareaUi = {
root: 'w-full',
base: 'min-h-[24rem] w-full bg-elevated/60 font-mono text-sm ring-default focus-visible:ring-primary',
};
const engineItems = [
{ label: 'HTTPX', value: 'httpx' },
{ label: 'Selenium', value: 'selenium' },
];
const requestMethodItems = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
];
const containerTypeItems = [
{ label: 'CSS', value: 'css' },
{ label: 'XPath', value: 'xpath' },
{ label: 'JSONPath', value: 'jsonpath' },
];
const fieldTypeItems = [
{ label: 'CSS', value: 'css' },
{ label: 'XPath', value: 'xpath' },
{ label: 'Regex', value: 'regex' },
{ label: 'JSONPath', value: 'jsonpath' },
];
const existingDefinitionItems = computed(() => {
return availableDefinitions.value.map((item) => ({
label: prettyName(item.name || String(item.id)),
value: item.id,
}));
});
const dirtySource = computed(() => ({
mode: mode.value,
showImport: showImport.value,
importString: importString.value,
selectedExisting: selectedExisting.value,
jsonText: jsonText.value,
guiState: JSON.parse(JSON.stringify(guiState)),
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
const resetGuiState = (state: GuiState): void => {
guiState.name = state.name;
guiState.priority = state.priority;
guiState.enabled = state.enabled;
guiState.matchText = state.matchText;
guiState.engineType = state.engineType;
guiState.engineUrl = state.engineUrl;
guiState.requestMethod = state.requestMethod;
guiState.requestUrl = state.requestUrl;
guiState.containerType = state.containerType;
guiState.containerSelector = state.containerSelector;
guiState.fields = state.fields.map((field) => ({ ...field }));
};
const defaultField = (): GuiField => ({ key: '', type: 'css', expression: '', attribute: '' });
const addField = (): void => {
guiState.fields.push(defaultField());
};
const removeField = (index: number): void => {
guiState.fields.splice(index, 1);
};
const splitMatches = (text: string): string[] => {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
};
const toGui = (document: TaskDefinitionDocument): GuiState | null => {
if (!document || Array.isArray(document) || typeof document !== 'object') {
return null;
}
const entry = document;
const match = entry.match_url;
if (!Array.isArray(match) || match.some((item) => typeof item !== 'string')) {
return null;
}
const definition = entry.definition;
if (!definition || Array.isArray(definition) || typeof definition !== 'object') {
return null;
}
const parse = definition.parse;
if (!parse || Array.isArray(parse) || typeof parse !== 'object') {
return null;
}
const parseRecord = parse as Record<string, unknown>;
const items = parseRecord.items;
if (!items || Array.isArray(items) || typeof items !== 'object') {
return null;
}
const itemRecord = items as Record<string, unknown>;
const fields = itemRecord.fields;
if (!fields || Array.isArray(fields) || typeof fields !== 'object') {
return null;
}
const fieldRecord = fields as Record<string, unknown>;
const guiFields: GuiField[] = [];
for (const [key, value] of Object.entries(fieldRecord)) {
if (!value || Array.isArray(value) || typeof value !== 'object') {
return null;
}
const rule = value as Record<string, unknown>;
if (typeof rule.type !== 'string' || typeof rule.expression !== 'string') {
return null;
}
if (
Object.keys(rule).some(
(prop) => !['type', 'expression', 'attribute', 'post_filter'].includes(prop),
)
) {
return null;
}
guiFields.push({
key,
type: String(rule.type),
expression: String(rule.expression),
attribute: typeof rule.attribute === 'string' ? String(rule.attribute) : '',
});
}
const engine = definition.engine as Record<string, unknown> | undefined;
const engineType = engine?.type === 'selenium' ? 'selenium' : 'httpx';
const engineUrl =
typeof engine?.options === 'string' && engineType === 'selenium'
? ''
: ((engine?.options as Record<string, unknown> | undefined)?.url as string | undefined);
if (engineUrl && engineType === 'selenium' && typeof engineUrl !== 'string') {
return null;
}
const request = definition.request as Record<string, unknown> | undefined;
const selectorType = String(itemRecord.type ?? 'css') as GuiState['containerType'];
const selectorSource = (itemRecord.selector ?? itemRecord.expression) as string | undefined;
if (!selectorSource || typeof selectorSource !== 'string') {
return null;
}
return {
name: typeof entry.name === 'string' ? entry.name : '',
priority: Number(entry.priority ?? 0) || 0,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
matchText: match.join('\n'),
engineType,
engineUrl: engineType === 'selenium' ? String(engineUrl ?? '') : '',
requestMethod: typeof request?.method === 'string' ? String(request.method) : 'GET',
requestUrl: typeof request?.url === 'string' ? String(request.url) : '',
containerType: selectorType,
containerSelector: selectorSource,
fields: guiFields.length ? guiFields : [defaultField()],
};
};
const fromGui = (state: GuiState): TaskDefinitionDocument => {
if (!state.name.trim()) {
throw new Error('Name is required.');
}
const matches = splitMatches(state.matchText);
if (!matches.length) {
throw new Error('At least one match pattern is required.');
}
if (!state.containerSelector.trim()) {
throw new Error('Container selector is required.');
}
const formattedFields: Record<string, Record<string, string>> = {};
state.fields.forEach((field) => {
if (!field.key.trim()) {
return;
}
if (!field.expression.trim()) {
throw new Error(`Expression is required for field "${field.key}".`);
}
formattedFields[field.key.trim()] = {
type: field.type || 'css',
expression: field.expression,
...(field.attribute ? { attribute: field.attribute } : {}),
};
});
if (!Object.keys(formattedFields).length) {
throw new Error('Configure at least one extractor field.');
}
const definition: Record<string, unknown> = {
parse: {
items: {
type: state.containerType,
selector: state.containerType === 'jsonpath' ? undefined : state.containerSelector,
expression: state.containerType === 'jsonpath' ? state.containerSelector : undefined,
fields: formattedFields,
},
},
};
if (state.engineType !== 'httpx' || state.engineUrl) {
definition.engine = {
type: state.engineType,
...(state.engineType === 'selenium' && state.engineUrl
? { options: { url: state.engineUrl } }
: {}),
};
}
const request: Record<string, string> = {};
if (state.requestMethod && state.requestMethod !== 'GET') {
request.method = state.requestMethod;
}
if (state.requestUrl) {
request.url = state.requestUrl;
}
if (Object.keys(request).length) {
definition.request = request;
}
return {
name: state.name.trim(),
priority: Number(state.priority) || 0,
enabled: state.enabled,
match_url: matches,
definition: definition as unknown as TaskDefinitionDocument['definition'],
};
};
const normalizeRequestConfig = (request: any): any => {
if (!request || typeof request !== 'object') {
return request;
}
if ('json' in request) {
const normalized = { ...request };
normalized.json_data = normalized.json;
delete normalized.json;
return normalized;
}
return request;
};
const parseImportedDocument = (payload: unknown): TaskDefinitionDocument => {
if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
throw new Error('Import payload is not a task definition object.');
}
const record = payload as Record<string, unknown>;
if ('_type' in record && record._type !== undefined && record._type !== 'task_definition') {
throw new Error('Import string is not a task definition export.');
}
const version = record._version as string | undefined;
if (!['1.0', '2.0'].includes(version ?? '')) {
throw new Error(
`Unsupported or missing _version field. Expected "1.0" or "2.0", got: ${version ?? 'undefined'}`,
);
}
let base: TaskDefinitionDocument;
if (version === '1.0') {
const oldDef = record.definition as Record<string, unknown>;
const oldMatch = Array.isArray(oldDef.match) ? oldDef.match : [];
const normalizedMatch: string[] = [];
for (const item of oldMatch) {
if (typeof item === 'string') {
normalizedMatch.push(item);
} else if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, unknown>;
if (typeof obj.regex === 'string') {
normalizedMatch.push(`/${obj.regex}/`);
} else if (typeof obj.glob === 'string') {
normalizedMatch.push(obj.glob);
}
}
}
base = {
name:
typeof oldDef.name === 'string'
? oldDef.name
: typeof record.name === 'string'
? record.name
: '',
priority: Number(oldDef.priority ?? record.priority ?? 0) || 0,
enabled: true,
match_url: normalizedMatch,
definition: {
parse: oldDef.parse as any,
engine: oldDef.engine as any,
request: normalizeRequestConfig(oldDef.request),
response: oldDef.response as any,
},
};
} else {
base = record as unknown as TaskDefinitionDocument;
}
return JSON.parse(JSON.stringify(base)) as TaskDefinitionDocument;
};
const parseDocument = (): TaskDefinitionDocument | null => {
try {
if (!jsonText.value.trim()) {
throw new Error('Definition cannot be empty.');
}
const parsed = JSON.parse(jsonText.value) as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('Definition must be a JSON object.');
}
errorMessage.value = null;
return parsed as TaskDefinitionDocument;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'Invalid JSON document.';
return null;
}
};
const applyDocument = (document: TaskDefinitionDocument | null): void => {
const shouldShowImport = props.initialShowImport ?? !document;
showImport.value = shouldShowImport;
importString.value = '';
selectedExisting.value = null;
guiError.value = null;
errorMessage.value = null;
if (!document) {
jsonText.value = '';
guiSupported.value = true;
resetGuiState({
name: '',
priority: 0,
enabled: true,
matchText: '',
engineType: 'httpx',
engineUrl: '',
requestMethod: 'GET',
requestUrl: '',
containerType: 'css',
containerSelector: '',
fields: [defaultField()],
});
nextTick(() => {
markClean();
emit('dirty-change', false);
});
return;
}
try {
jsonText.value = JSON.stringify(document, null, 2);
const gui = toGui(document);
if (gui) {
guiSupported.value = true;
resetGuiState(gui);
if (mode.value !== 'gui') {
mode.value = 'gui';
}
} else {
guiSupported.value = false;
mode.value = 'advanced';
}
} catch (error) {
console.error('Failed to prepare definition for editing.', error);
jsonText.value = '';
guiSupported.value = false;
mode.value = 'advanced';
errorMessage.value = 'Failed to prepare definition for editing.';
}
nextTick(() => {
markClean();
emit('dirty-change', false);
});
};
const importFromString = (): void => {
if (isBusy.value) {
return;
}
if (!importString.value.trim()) {
guiError.value = 'Import string cannot be empty.';
return;
}
try {
const decoded = decode(importString.value.trim());
const document = parseImportedDocument(decoded);
applyDocument(document);
importString.value = '';
showImport.value = false;
} catch (error) {
guiError.value = error instanceof Error ? error.message : 'Unable to import definition.';
}
};
const importExisting = (): void => {
if (!selectedExisting.value || isBusy.value) {
return;
}
emit('import-existing', Number(selectedExisting.value));
selectedExisting.value = null;
};
watch(
() => props.document,
(doc) => applyDocument(doc),
{ immediate: true },
);
watch(isDirty, (value: boolean) => emit('dirty-change', value));
const switchMode = (next: EditorMode): void => {
if (isBusy.value || next === mode.value) {
return;
}
if (next === 'gui') {
if (!guiSupported.value) {
return;
}
const parsed = parseDocument();
if (!parsed) {
return;
}
const gui = toGui(parsed);
if (!gui) {
guiSupported.value = false;
return;
}
resetGuiState(gui);
guiSupported.value = true;
}
if (next === 'advanced') {
try {
const doc = fromGui(guiState);
jsonText.value = JSON.stringify(doc, null, 2);
errorMessage.value = null;
guiError.value = null;
} catch (error) {
guiError.value = error instanceof Error ? error.message : 'Failed to serialize GUI changes.';
return;
}
}
mode.value = next;
};
const submit = (): void => {
if (isBusy.value) {
return;
}
if (mode.value === 'gui') {
try {
const doc = fromGui(guiState);
emit('submit', doc);
guiError.value = null;
} catch (error) {
guiError.value = error instanceof Error ? error.message : 'Unable to build definition.';
}
return;
}
const parsed = parseDocument();
if (!parsed) {
return;
}
emit('submit', parsed);
};
const beautify = (): void => {
if (mode.value !== 'advanced') {
return;
}
const parsed = parseDocument();
if (!parsed) {
return;
}
jsonText.value = JSON.stringify(parsed, null, 2);
errorMessage.value = null;
};
const cancel = (): void => {
if (submitting.value) {
return;
}
emit('cancel');
};
defineExpose({ submit, beautify });
</script>