a2a4c6fc9a
- Add OCR_LANGUAGES constant (28 languages, EN/DE/FR/ES/IT/PT/RU/ZH/JA/KO/AR/etc.) - Add TESSERACT_TO_EASYOCR mapping for automatic code translation - Add optional language constructor arg to TesseractOCRProvider/EasyOCRProvider - Update get_ocr_providers() to accept and pass per-call language override - Add language parameter to process_with_ocr Celery task - Add _get_pipeline_ocr_language() helper to resolve OCR language from pipeline step config - Update process_document to look up and pass pipeline OCR language to process_with_ocr - Add ocr_language select config field (28 options) to pipeline OCR step schema - Add language dropdown to pipeline UI (pipelines.html) - Update docs/UserGuide.md and docs/API.md with language override documentation - Add 27 new tests covering language constants, provider overrides, and pipeline lookup Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
911 lines
39 KiB
HTML
911 lines
39 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Processing Pipelines – DocuElevate{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container mx-auto px-4 py-8" x-data="pipelinesApp()" x-init="init()">
|
||
|
||
<!-- ── Header ─────────────────────────────────────────────────────────────── -->
|
||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<i class="fas fa-project-diagram text-blue-500" aria-hidden="true"></i>
|
||
Processing Pipelines
|
||
</h1>
|
||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||
Define and manage custom document processing workflows.
|
||
System pipelines (created by admins) are shown with a
|
||
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1">System</span>
|
||
badge and are visible to all users.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="openCreatePipelineModal()"
|
||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||
style="min-height:44px;"
|
||
>
|
||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> New Pipeline
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
|
||
<!-- Two containers with static aria-live so screen readers register them on page load -->
|
||
<div aria-live="polite" aria-atomic="true">
|
||
<div x-show="alert.show && alert.type !== 'error'" x-transition class="mb-4" role="status">
|
||
<div class="bg-green-50 border-green-400 text-green-800 border-l-4 p-4 rounded dark:bg-opacity-10">
|
||
<p class="font-semibold" x-text="alert.title"></p>
|
||
<p class="text-sm" x-text="alert.message"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div aria-live="assertive" aria-atomic="true">
|
||
<div x-show="alert.show && alert.type === 'error'" x-transition class="mb-4" role="alert">
|
||
<div class="bg-red-50 border-red-400 text-red-800 border-l-4 p-4 rounded dark:bg-opacity-10">
|
||
<p class="font-semibold" x-text="alert.title"></p>
|
||
<p class="text-sm" x-text="alert.message"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Loading ────────────────────────────────────────────────────────────── -->
|
||
<template x-if="loading">
|
||
<div class="text-center py-12 text-gray-400">
|
||
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
|
||
<p>Loading pipelines…</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ── Empty state ────────────────────────────────────────────────────────── -->
|
||
<template x-if="!loading && pipelines.length === 0">
|
||
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||
<i class="fas fa-project-diagram text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
|
||
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No pipelines yet</h2>
|
||
<p class="text-gray-500 dark:text-gray-400 mb-6">Create your first pipeline to define custom document processing workflows.</p>
|
||
<button
|
||
type="button"
|
||
@click="openCreatePipelineModal()"
|
||
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md"
|
||
style="min-height:44px;"
|
||
>
|
||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Create Pipeline
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ── Pipeline cards ─────────────────────────────────────────────────────── -->
|
||
<template x-if="!loading && pipelines.length > 0">
|
||
<div class="space-y-4">
|
||
<template x-for="pipeline in pipelines" :key="pipeline.id">
|
||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||
|
||
<!-- Card header -->
|
||
<div class="px-5 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-gray-100 dark:border-gray-700">
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="pipeline.name"></h2>
|
||
<!-- Badges -->
|
||
<template x-if="pipeline.owner_id === null">
|
||
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5">System</span>
|
||
</template>
|
||
<template x-if="pipeline.is_default">
|
||
<span class="text-xs font-semibold text-green-700 bg-green-50 border border-green-200 rounded px-1.5 py-0.5">Default</span>
|
||
</template>
|
||
<template x-if="!pipeline.is_active">
|
||
<span class="text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded px-1.5 py-0.5">Inactive</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex items-center gap-2 flex-shrink-0">
|
||
<button
|
||
type="button"
|
||
@click="toggleSteps(pipeline)"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
|
||
style="min-height:36px;"
|
||
:aria-expanded="pipeline._expanded"
|
||
:aria-label="`${pipeline._expanded ? 'Collapse' : 'Expand'} steps for ${pipeline.name}`"
|
||
>
|
||
<i class="fas fa-list-ul mr-1" aria-hidden="true"></i>
|
||
<span x-text="(pipeline.steps || []).length + ' step' + ((pipeline.steps || []).length !== 1 ? 's' : '')"></span>
|
||
<i :class="pipeline._expanded ? 'fa-chevron-up' : 'fa-chevron-down'" class="fas ml-1 text-xs" aria-hidden="true"></i>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="openEditPipelineModal(pipeline)"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md"
|
||
style="min-height:36px;"
|
||
:aria-label="`Edit pipeline ${pipeline.name}`"
|
||
>
|
||
<i class="fas fa-pencil-alt mr-1" aria-hidden="true"></i> Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="confirmDeletePipeline(pipeline)"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-md"
|
||
style="min-height:36px;"
|
||
:aria-label="`Delete pipeline ${pipeline.name}`"
|
||
>
|
||
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<template x-if="pipeline.description">
|
||
<p class="px-5 pt-3 text-sm text-gray-500 dark:text-gray-400" x-text="pipeline.description"></p>
|
||
</template>
|
||
|
||
<!-- Steps (collapsible) -->
|
||
<div x-show="pipeline._expanded" x-transition class="px-5 py-4">
|
||
|
||
<!-- Step list -->
|
||
<template x-if="(pipeline.steps || []).length === 0">
|
||
<p class="text-sm text-gray-400 italic mb-3">No steps defined. Add a step to start building your pipeline.</p>
|
||
</template>
|
||
|
||
<template x-if="(pipeline.steps || []).length > 0">
|
||
<ol class="space-y-2 mb-4" role="list" :aria-label="`Steps for ${pipeline.name}`">
|
||
<template x-for="(step, idx) in (pipeline.steps || [])" :key="step.id">
|
||
<li class="flex items-center justify-between bg-gray-50 dark:bg-gray-700 rounded-md px-4 py-2.5 gap-3">
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-xs font-bold flex items-center justify-center" x-text="idx + 1" aria-hidden="true"></span>
|
||
<div class="min-w-0">
|
||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" x-text="step.label || stepTypeLabel(step.step_type)"></p>
|
||
<p class="text-xs text-gray-400 truncate" x-text="step.step_type"></p>
|
||
</div>
|
||
<template x-if="!step.enabled">
|
||
<span class="text-xs text-gray-400 bg-gray-200 rounded px-1.5 py-0.5 flex-shrink-0">Disabled</span>
|
||
</template>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||
<button
|
||
type="button"
|
||
@click="openEditStepModal(pipeline, step)"
|
||
class="p-1.5 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900 rounded"
|
||
style="min-height:36px;min-width:36px;"
|
||
:aria-label="`Edit step ${step.label || step.step_type}`"
|
||
><i class="fas fa-pencil-alt text-xs" aria-hidden="true"></i></button>
|
||
<button
|
||
type="button"
|
||
@click="moveStep(pipeline, idx, -1)"
|
||
:disabled="idx === 0"
|
||
class="p-1.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600 rounded disabled:opacity-30"
|
||
style="min-height:36px;min-width:36px;"
|
||
:aria-label="`Move step up: ${step.label || step.step_type}`"
|
||
><i class="fas fa-arrow-up text-xs" aria-hidden="true"></i></button>
|
||
<button
|
||
type="button"
|
||
@click="moveStep(pipeline, idx, 1)"
|
||
:disabled="idx === (pipeline.steps || []).length - 1"
|
||
class="p-1.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600 rounded disabled:opacity-30"
|
||
style="min-height:36px;min-width:36px;"
|
||
:aria-label="`Move step down: ${step.label || step.step_type}`"
|
||
><i class="fas fa-arrow-down text-xs" aria-hidden="true"></i></button>
|
||
<button
|
||
type="button"
|
||
@click="confirmDeleteStep(pipeline, step)"
|
||
class="p-1.5 text-red-400 hover:bg-red-50 dark:hover:bg-red-900 rounded"
|
||
style="min-height:36px;min-width:36px;"
|
||
:aria-label="`Delete step ${step.label || step.step_type}`"
|
||
><i class="fas fa-times text-xs" aria-hidden="true"></i></button>
|
||
</div>
|
||
</li>
|
||
</template>
|
||
</ol>
|
||
</template>
|
||
|
||
<!-- Add step button -->
|
||
<button
|
||
type="button"
|
||
@click="openAddStepModal(pipeline)"
|
||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-green-600 bg-green-50 hover:bg-green-100 rounded-md border border-green-200"
|
||
style="min-height:36px;"
|
||
:aria-label="`Add step to ${pipeline.name}`"
|
||
>
|
||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Step
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||
<!-- Modal: Create / Edit Pipeline -->
|
||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||
<div
|
||
x-show="pipelineModal.open"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-labelledby="pipelineModal.editMode ? 'editPipelineTitle' : 'createPipelineTitle'"
|
||
@keydown.escape.window="closePipelineModal()"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6" @click.stop>
|
||
<h2
|
||
:id="pipelineModal.editMode ? 'editPipelineTitle' : 'createPipelineTitle'"
|
||
class="text-xl font-semibold text-gray-900 dark:text-white mb-5"
|
||
x-text="pipelineModal.editMode ? 'Edit Pipeline' : 'New Pipeline'"
|
||
></h2>
|
||
|
||
<form @submit.prevent="savePipeline()" novalidate>
|
||
<div class="space-y-4">
|
||
|
||
<!-- Name -->
|
||
<div>
|
||
<label for="pipelineName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Name <span aria-hidden="true" class="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
id="pipelineName"
|
||
type="text"
|
||
x-model="pipelineModal.form.name"
|
||
maxlength="255"
|
||
required
|
||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||
placeholder="My pipeline"
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<div>
|
||
<label for="pipelineDesc" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||
<textarea
|
||
id="pipelineDesc"
|
||
x-model="pipelineModal.form.description"
|
||
rows="2"
|
||
maxlength="4096"
|
||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white resize-none"
|
||
placeholder="Optional description"
|
||
></textarea>
|
||
</div>
|
||
|
||
<!-- Flags -->
|
||
<div class="flex flex-col gap-2">
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
x-model="pipelineModal.form.is_default"
|
||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">Set as my default pipeline</span>
|
||
</label>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
x-model="pipelineModal.form.is_active"
|
||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- System pipeline option (admin only, create only) -->
|
||
<template x-if="isAdmin && !pipelineModal.editMode">
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
x-model="pipelineModal.form.system"
|
||
class="rounded border-gray-300 text-purple-600 focus:ring-purple-400"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">System pipeline (visible to all users)</span>
|
||
</label>
|
||
</template>
|
||
|
||
<!-- Error message -->
|
||
<p x-show="pipelineModal.error" class="text-sm text-red-600" x-text="pipelineModal.error" role="alert"></p>
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3 mt-6">
|
||
<button
|
||
type="button"
|
||
@click="closePipelineModal()"
|
||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
|
||
style="min-height:44px;"
|
||
>Cancel</button>
|
||
<button
|
||
type="submit"
|
||
:disabled="pipelineModal.saving"
|
||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60 rounded-md"
|
||
style="min-height:44px;"
|
||
>
|
||
<template x-if="pipelineModal.saving">
|
||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||
</template>
|
||
<span x-text="pipelineModal.editMode ? 'Save Changes' : 'Create'"></span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||
<!-- Modal: Add / Edit Step -->
|
||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||
<div
|
||
x-show="stepModal.open"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-labelledby="stepModal.editMode ? 'editStepTitle' : 'addStepTitle'"
|
||
@keydown.escape.window="closeStepModal()"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 p-6" @click.stop>
|
||
<h2
|
||
:id="stepModal.editMode ? 'editStepTitle' : 'addStepTitle'"
|
||
class="text-xl font-semibold text-gray-900 dark:text-white mb-5"
|
||
x-text="stepModal.editMode ? 'Edit Step' : 'Add Step'"
|
||
></h2>
|
||
|
||
<form @submit.prevent="saveStep()" novalidate>
|
||
<div class="space-y-4">
|
||
|
||
<!-- Step type -->
|
||
<div>
|
||
<label for="stepType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Step Type <span aria-hidden="true" class="text-red-500">*</span>
|
||
</label>
|
||
<select
|
||
id="stepType"
|
||
x-model="stepModal.form.step_type"
|
||
:disabled="stepModal.editMode"
|
||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="">— select —</option>
|
||
<template x-for="[key, meta] in Object.entries(stepTypes)" :key="key">
|
||
<option :value="key" x-text="meta.label"></option>
|
||
</template>
|
||
</select>
|
||
<template x-if="stepModal.form.step_type && stepTypes[stepModal.form.step_type]">
|
||
<p class="mt-1 text-xs text-gray-400" x-text="stepTypes[stepModal.form.step_type].description"></p>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Custom label -->
|
||
<div>
|
||
<label for="stepLabel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Custom Label <span class="text-gray-400 font-normal">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="stepLabel"
|
||
type="text"
|
||
x-model="stepModal.form.label"
|
||
maxlength="255"
|
||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||
placeholder="Override the default step name"
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<!-- force_cloud_ocr (only shown for ocr step) -->
|
||
<template x-if="stepModal.form.step_type === 'ocr'">
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
x-model="stepModal.form.config.force_cloud_ocr"
|
||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">Force cloud OCR (skip local text extraction)</span>
|
||
</label>
|
||
</template>
|
||
|
||
<!-- ocr_language (only shown for ocr step) -->
|
||
<template x-if="stepModal.form.step_type === 'ocr'">
|
||
<div>
|
||
<label for="ocrLanguage" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
OCR Language
|
||
</label>
|
||
<select
|
||
id="ocrLanguage"
|
||
x-model="stepModal.form.config.ocr_language"
|
||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="auto">Auto (use system default)</option>
|
||
<option value="ara">Arabic</option>
|
||
<option value="chi_sim">Chinese (Simplified)</option>
|
||
<option value="chi_tra">Chinese (Traditional)</option>
|
||
<option value="ces">Czech</option>
|
||
<option value="dan">Danish</option>
|
||
<option value="nld">Dutch</option>
|
||
<option value="eng">English</option>
|
||
<option value="fin">Finnish</option>
|
||
<option value="fra">French</option>
|
||
<option value="deu">German</option>
|
||
<option value="ell">Greek</option>
|
||
<option value="heb">Hebrew</option>
|
||
<option value="hin">Hindi</option>
|
||
<option value="hun">Hungarian</option>
|
||
<option value="ita">Italian</option>
|
||
<option value="jpn">Japanese</option>
|
||
<option value="kor">Korean</option>
|
||
<option value="nor">Norwegian</option>
|
||
<option value="pol">Polish</option>
|
||
<option value="por">Portuguese</option>
|
||
<option value="ron">Romanian</option>
|
||
<option value="rus">Russian</option>
|
||
<option value="spa">Spanish</option>
|
||
<option value="swe">Swedish</option>
|
||
<option value="tha">Thai</option>
|
||
<option value="tur">Turkish</option>
|
||
<option value="ukr">Ukrainian</option>
|
||
<option value="vie">Vietnamese</option>
|
||
</select>
|
||
<p class="mt-1 text-xs text-gray-400">
|
||
Overrides the global language setting for Tesseract/EasyOCR. Azure and Mistral auto-detect the language.
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Enabled -->
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
x-model="stepModal.form.enabled"
|
||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
|
||
</label>
|
||
|
||
<!-- Error -->
|
||
<p x-show="stepModal.error" class="text-sm text-red-600" x-text="stepModal.error" role="alert"></p>
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3 mt-6">
|
||
<button
|
||
type="button"
|
||
@click="closeStepModal()"
|
||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
|
||
style="min-height:44px;"
|
||
>Cancel</button>
|
||
<button
|
||
type="submit"
|
||
:disabled="stepModal.saving"
|
||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60 rounded-md"
|
||
style="min-height:44px;"
|
||
>
|
||
<template x-if="stepModal.saving">
|
||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||
</template>
|
||
<span x-text="stepModal.editMode ? 'Save Changes' : 'Add Step'"></span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||
<!-- Confirm delete dialog -->
|
||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||
<div
|
||
x-show="confirmModal.open"
|
||
x-transition:enter="transition ease-out duration-150"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-100"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||
role="alertdialog"
|
||
aria-modal="true"
|
||
aria-labelledby="confirmTitle"
|
||
@keydown.escape.window="confirmModal.open = false"
|
||
>
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm mx-4 p-6" @click.stop>
|
||
<h2 id="confirmTitle" class="text-lg font-semibold text-gray-900 dark:text-white mb-2" x-text="confirmModal.title"></h2>
|
||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6" x-text="confirmModal.message"></p>
|
||
<div class="flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
@click="confirmModal.open = false"
|
||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
|
||
style="min-height:44px;"
|
||
>Cancel</button>
|
||
<button
|
||
type="button"
|
||
@click="confirmModal.action(); confirmModal.open = false"
|
||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md"
|
||
style="min-height:44px;"
|
||
>Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<script>
|
||
function pipelinesApp() {
|
||
return {
|
||
loading: true,
|
||
pipelines: [],
|
||
stepTypes: {},
|
||
isAdmin: false,
|
||
|
||
alert: { show: false, type: 'success', title: '', message: '' },
|
||
|
||
pipelineModal: {
|
||
open: false,
|
||
editMode: false,
|
||
pipelineId: null,
|
||
saving: false,
|
||
error: '',
|
||
form: { name: '', description: '', is_default: false, is_active: true, system: false },
|
||
},
|
||
|
||
stepModal: {
|
||
open: false,
|
||
editMode: false,
|
||
pipeline: null,
|
||
stepId: null,
|
||
saving: false,
|
||
error: '',
|
||
form: { step_type: '', label: '', config: {}, enabled: true },
|
||
},
|
||
|
||
confirmModal: {
|
||
open: false,
|
||
title: '',
|
||
message: '',
|
||
action: () => {},
|
||
},
|
||
|
||
// ── Initialisation ────────────────────────────────────────────────────
|
||
|
||
async init() {
|
||
await Promise.all([this.fetchStepTypes(), this.fetchCurrentUser()]);
|
||
await this.fetchPipelines();
|
||
},
|
||
|
||
async fetchCurrentUser() {
|
||
try {
|
||
const r = await fetch('/api/auth/whoami');
|
||
if (r.ok) {
|
||
const u = await r.json();
|
||
this.isAdmin = !!u.is_admin;
|
||
}
|
||
} catch (_) {}
|
||
},
|
||
|
||
async fetchStepTypes() {
|
||
try {
|
||
const r = await fetch('/api/pipelines/step-types');
|
||
if (r.ok) this.stepTypes = await r.json();
|
||
} catch (_) {}
|
||
},
|
||
|
||
async fetchPipelines() {
|
||
this.loading = true;
|
||
try {
|
||
const r = await fetch('/api/pipelines');
|
||
if (!r.ok) throw new Error(await r.text());
|
||
const list = await r.json();
|
||
// Fetch steps for each pipeline
|
||
const detailed = await Promise.all(list.map(p => this.fetchPipeline(p.id)));
|
||
this.pipelines = detailed.map(p => ({ ...p, _expanded: false }));
|
||
} catch (err) {
|
||
this.showAlert('error', 'Failed to load pipelines', err.message || String(err));
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async fetchPipeline(id) {
|
||
const r = await fetch(`/api/pipelines/${id}`);
|
||
if (!r.ok) throw new Error(`Pipeline ${id} not found`);
|
||
return r.json();
|
||
},
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────
|
||
|
||
stepTypeLabel(key) {
|
||
return this.stepTypes[key] ? this.stepTypes[key].label : key;
|
||
},
|
||
|
||
showAlert(type, title, message) {
|
||
this.alert = { show: true, type, title, message };
|
||
setTimeout(() => { this.alert.show = false; }, 6000);
|
||
},
|
||
|
||
toggleSteps(pipeline) {
|
||
pipeline._expanded = !pipeline._expanded;
|
||
},
|
||
|
||
// ── Pipeline Modal ────────────────────────────────────────────────────
|
||
|
||
openCreatePipelineModal() {
|
||
this.pipelineModal = {
|
||
open: true,
|
||
editMode: false,
|
||
pipelineId: null,
|
||
saving: false,
|
||
error: '',
|
||
form: { name: '', description: '', is_default: false, is_active: true, system: false },
|
||
};
|
||
},
|
||
|
||
openEditPipelineModal(pipeline) {
|
||
this.pipelineModal = {
|
||
open: true,
|
||
editMode: true,
|
||
pipelineId: pipeline.id,
|
||
saving: false,
|
||
error: '',
|
||
form: {
|
||
name: pipeline.name,
|
||
description: pipeline.description || '',
|
||
is_default: pipeline.is_default,
|
||
is_active: pipeline.is_active,
|
||
system: false,
|
||
},
|
||
};
|
||
},
|
||
|
||
closePipelineModal() {
|
||
this.pipelineModal.open = false;
|
||
},
|
||
|
||
async savePipeline() {
|
||
const fm = this.pipelineModal.form;
|
||
if (!fm.name.trim()) {
|
||
this.pipelineModal.error = 'Pipeline name is required.';
|
||
return;
|
||
}
|
||
this.pipelineModal.error = '';
|
||
this.pipelineModal.saving = true;
|
||
|
||
try {
|
||
let r;
|
||
if (this.pipelineModal.editMode) {
|
||
r = await fetch(`/api/pipelines/${this.pipelineModal.pipelineId}`, {
|
||
method: 'PUT',
|
||
headers: this._jsonHeaders(),
|
||
body: JSON.stringify({
|
||
name: fm.name.trim(),
|
||
description: fm.description || null,
|
||
is_default: fm.is_default,
|
||
is_active: fm.is_active,
|
||
}),
|
||
});
|
||
} else {
|
||
const url = fm.system ? '/api/pipelines/admin/system' : '/api/pipelines';
|
||
r = await fetch(url, {
|
||
method: 'POST',
|
||
headers: this._jsonHeaders(),
|
||
body: JSON.stringify({
|
||
name: fm.name.trim(),
|
||
description: fm.description || null,
|
||
is_default: fm.is_default,
|
||
is_active: fm.is_active,
|
||
}),
|
||
});
|
||
}
|
||
if (!r.ok) {
|
||
const e = await r.json().catch(() => ({}));
|
||
throw new Error(e.detail || `HTTP ${r.status}`);
|
||
}
|
||
const saved = await r.json();
|
||
if (this.pipelineModal.editMode) {
|
||
const idx = this.pipelines.findIndex(p => p.id === saved.id);
|
||
if (idx !== -1) {
|
||
const expanded = this.pipelines[idx]._expanded;
|
||
this.pipelines[idx] = { ...saved, steps: this.pipelines[idx].steps || [], _expanded: expanded };
|
||
}
|
||
} else {
|
||
this.pipelines.push({ ...saved, steps: [], _expanded: false });
|
||
}
|
||
this.closePipelineModal();
|
||
this.showAlert('success', 'Pipeline saved', `"${saved.name}" has been ${this.pipelineModal.editMode ? 'updated' : 'created'}.`);
|
||
} catch (err) {
|
||
this.pipelineModal.error = err.message || 'Unknown error';
|
||
} finally {
|
||
this.pipelineModal.saving = false;
|
||
}
|
||
},
|
||
|
||
confirmDeletePipeline(pipeline) {
|
||
this.confirmModal = {
|
||
open: true,
|
||
title: 'Delete pipeline?',
|
||
message: `Are you sure you want to permanently delete "${pipeline.name}" and all its steps? This cannot be undone.`,
|
||
action: () => this.deletePipeline(pipeline),
|
||
};
|
||
},
|
||
|
||
async deletePipeline(pipeline) {
|
||
try {
|
||
const r = await fetch(`/api/pipelines/${pipeline.id}`, { method: 'DELETE', headers: this._csrfHeaders() });
|
||
if (!r.ok) {
|
||
const e = await r.json().catch(() => ({}));
|
||
throw new Error(e.detail || `HTTP ${r.status}`);
|
||
}
|
||
this.pipelines = this.pipelines.filter(p => p.id !== pipeline.id);
|
||
this.showAlert('success', 'Pipeline deleted', `"${pipeline.name}" has been deleted.`);
|
||
} catch (err) {
|
||
this.showAlert('error', 'Delete failed', err.message || String(err));
|
||
}
|
||
},
|
||
|
||
// ── Step Modal ────────────────────────────────────────────────────────
|
||
|
||
openAddStepModal(pipeline) {
|
||
this.stepModal = {
|
||
open: true,
|
||
editMode: false,
|
||
pipeline,
|
||
stepId: null,
|
||
saving: false,
|
||
error: '',
|
||
form: { step_type: '', label: '', config: {}, enabled: true },
|
||
};
|
||
},
|
||
|
||
openEditStepModal(pipeline, step) {
|
||
this.stepModal = {
|
||
open: true,
|
||
editMode: true,
|
||
pipeline,
|
||
stepId: step.id,
|
||
saving: false,
|
||
error: '',
|
||
form: {
|
||
step_type: step.step_type,
|
||
label: step.label || '',
|
||
config: { ...(step.config || {}) },
|
||
enabled: step.enabled,
|
||
},
|
||
};
|
||
},
|
||
|
||
closeStepModal() {
|
||
this.stepModal.open = false;
|
||
},
|
||
|
||
async saveStep() {
|
||
const fm = this.stepModal.form;
|
||
if (!fm.step_type) {
|
||
this.stepModal.error = 'Step type is required.';
|
||
return;
|
||
}
|
||
this.stepModal.error = '';
|
||
this.stepModal.saving = true;
|
||
const pid = this.stepModal.pipeline.id;
|
||
|
||
try {
|
||
let r;
|
||
if (this.stepModal.editMode) {
|
||
r = await fetch(`/api/pipelines/${pid}/steps/${this.stepModal.stepId}`, {
|
||
method: 'PUT',
|
||
headers: this._jsonHeaders(),
|
||
body: JSON.stringify({
|
||
step_type: fm.step_type,
|
||
label: fm.label || null,
|
||
config: fm.config || {},
|
||
enabled: fm.enabled,
|
||
}),
|
||
});
|
||
} else {
|
||
r = await fetch(`/api/pipelines/${pid}/steps`, {
|
||
method: 'POST',
|
||
headers: this._jsonHeaders(),
|
||
body: JSON.stringify({
|
||
step_type: fm.step_type,
|
||
label: fm.label || null,
|
||
config: fm.config || {},
|
||
enabled: fm.enabled,
|
||
}),
|
||
});
|
||
}
|
||
if (!r.ok) {
|
||
const e = await r.json().catch(() => ({}));
|
||
throw new Error(e.detail || `HTTP ${r.status}`);
|
||
}
|
||
const savedStep = await r.json();
|
||
// Refresh the pipeline's steps
|
||
const updated = await this.fetchPipeline(pid);
|
||
const pIdx = this.pipelines.findIndex(p => p.id === pid);
|
||
if (pIdx !== -1) {
|
||
const expanded = this.pipelines[pIdx]._expanded;
|
||
this.pipelines[pIdx] = { ...updated, _expanded: expanded };
|
||
}
|
||
this.closeStepModal();
|
||
this.showAlert('success', 'Step saved', `Step "${savedStep.label || savedStep.step_type}" has been ${this.stepModal.editMode ? 'updated' : 'added'}.`);
|
||
} catch (err) {
|
||
this.stepModal.error = err.message || 'Unknown error';
|
||
} finally {
|
||
this.stepModal.saving = false;
|
||
}
|
||
},
|
||
|
||
async moveStep(pipeline, idx, direction) {
|
||
const steps = pipeline.steps || [];
|
||
const newIdx = idx + direction;
|
||
if (newIdx < 0 || newIdx >= steps.length) return;
|
||
// Build the new order
|
||
const reordered = [...steps];
|
||
[reordered[idx], reordered[newIdx]] = [reordered[newIdx], reordered[idx]];
|
||
const ids = reordered.map(s => s.id);
|
||
try {
|
||
const r = await fetch(`/api/pipelines/${pipeline.id}/steps/reorder`, {
|
||
method: 'PUT',
|
||
headers: this._jsonHeaders(),
|
||
body: JSON.stringify(ids),
|
||
});
|
||
if (!r.ok) {
|
||
const e = await r.json().catch(() => ({}));
|
||
throw new Error(e.detail || `HTTP ${r.status}`);
|
||
}
|
||
const updated = await this.fetchPipeline(pipeline.id);
|
||
const pIdx = this.pipelines.findIndex(p => p.id === pipeline.id);
|
||
if (pIdx !== -1) {
|
||
this.pipelines[pIdx] = { ...updated, _expanded: true };
|
||
}
|
||
} catch (err) {
|
||
this.showAlert('error', 'Reorder failed', err.message || String(err));
|
||
}
|
||
},
|
||
|
||
confirmDeleteStep(pipeline, step) {
|
||
this.confirmModal = {
|
||
open: true,
|
||
title: 'Delete step?',
|
||
message: `Remove "${step.label || step.step_type}" from pipeline "${pipeline.name}"?`,
|
||
action: () => this.deleteStep(pipeline, step),
|
||
};
|
||
},
|
||
|
||
async deleteStep(pipeline, step) {
|
||
try {
|
||
const r = await fetch(`/api/pipelines/${pipeline.id}/steps/${step.id}`, {
|
||
method: 'DELETE',
|
||
headers: this._csrfHeaders(),
|
||
});
|
||
if (!r.ok) {
|
||
const e = await r.json().catch(() => ({}));
|
||
throw new Error(e.detail || `HTTP ${r.status}`);
|
||
}
|
||
const updated = await this.fetchPipeline(pipeline.id);
|
||
const pIdx = this.pipelines.findIndex(p => p.id === pipeline.id);
|
||
if (pIdx !== -1) {
|
||
this.pipelines[pIdx] = { ...updated, _expanded: true };
|
||
}
|
||
this.showAlert('success', 'Step deleted', `"${step.label || step.step_type}" removed.`);
|
||
} catch (err) {
|
||
this.showAlert('error', 'Delete failed', err.message || String(err));
|
||
}
|
||
},
|
||
|
||
// ── Fetch helpers ─────────────────────────────────────────────────────
|
||
|
||
_csrfToken() {
|
||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||
},
|
||
|
||
_csrfHeaders() {
|
||
return { 'X-CSRF-Token': this._csrfToken() };
|
||
},
|
||
|
||
_jsonHeaders() {
|
||
return { 'Content-Type': 'application/json', 'X-CSRF-Token': this._csrfToken() };
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|