acadmic texts
This commit is contained in:
0
.dockerignore
Normal file → Executable file
0
.dockerignore
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
1
Dockerfile
Normal file → Executable file
1
Dockerfile
Normal file → Executable file
@@ -36,6 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pandoc texlive-xetex \
|
||||
texlive-latex-recommended \
|
||||
texlive-fonts-recommended \
|
||||
unpaper \
|
||||
calibre \
|
||||
ffmpeg \
|
||||
libvips-tools \
|
||||
|
||||
0
docker-compose.yml
Normal file → Executable file
0
docker-compose.yml
Normal file → Executable file
0
requirements.txt
Normal file → Executable file
0
requirements.txt
Normal file → Executable file
2
run.sh
2
run.sh
@@ -9,7 +9,7 @@ SECRET_KEY=
|
||||
UPLOADS_DIR=./uploads
|
||||
PROCESSED_DIR=./processed
|
||||
# Start Gunicorn in the background
|
||||
gunicorn -w 4 --threads 2 -k uvicorn.workers.UvicornWorker --forwarded-allow-ips='*' --error-logfile - --access-logfile - main:app -b 0.0.0.0:8000 &
|
||||
gunicorn -w 4 --threads 2 -k uvicorn.workers.UvicornWorker --forwarded-allow-ips='*' --error-logfile - --access-logfile - main:app -b 0.0.0.0:8008 &
|
||||
echo "Started Gunicorn..."
|
||||
# Store the Gunicorn process ID
|
||||
GUNICORN_PID=$!
|
||||
|
||||
24
settings.default.yml
Normal file → Executable file
24
settings.default.yml
Normal file → Executable file
@@ -6,11 +6,11 @@ auth_settings:
|
||||
oidc_end_session_endpoint: https://accounts.example.com/oidc/session/end
|
||||
admin_users:
|
||||
- user@example.com
|
||||
web_hook_settings:
|
||||
webhook_settings:
|
||||
enabled: False
|
||||
allow_chunked_api_uploads": False
|
||||
allowed_callback_urls:
|
||||
callback_bearer_token":
|
||||
allow_chunked_api_uploads: False
|
||||
allowed_callback_urls: []
|
||||
callback_bearer_token:
|
||||
tts_settings:
|
||||
piper:
|
||||
model_dir: "./models/tts"
|
||||
@@ -99,6 +99,13 @@ ocr_settings:
|
||||
clean: true
|
||||
optimize: 1
|
||||
force_ocr: true
|
||||
academic_settings:
|
||||
pandoc:
|
||||
csl_files:
|
||||
apa: https://www.zotero.org/styles/apa
|
||||
mla: https://www.zotero.org/styles/modern-language-association
|
||||
chicago: https://www.zotero.org/styles/chicago-author-date
|
||||
chicago-fullnote: https://www.zotero.org/styles/chicago-fullnote-bibliography
|
||||
transcription_settings:
|
||||
whisper:
|
||||
compute_type: int8
|
||||
@@ -371,3 +378,12 @@ conversion_tools:
|
||||
jpg_q85: JPEG (High Quality)
|
||||
jpg_q75: JPEG (Web Quality)
|
||||
jpg_q60: JPEG (Aggressive Compression)
|
||||
pandoc_academic:
|
||||
name: Pandoc (Academic Document)
|
||||
command_template: "pandoc {main_document} -o {output} --bibliography {bib_file} --citeproc --csl {csl_style}"
|
||||
timeout: 300
|
||||
formats:
|
||||
pdf_apa: "PDF with Bibliography (APA Style)"
|
||||
pdf_mla: "PDF with Bibliography (MLA Style)"
|
||||
pdf_chicago: "PDF with Bibliography (Chicago Style)"
|
||||
pdf_chicago_fullnote: "PDF with Bibliography (Chicago Full Note)"
|
||||
|
||||
60
static/css/settings.css
Normal file → Executable file
60
static/css/settings.css
Normal file → Executable file
@@ -9,13 +9,8 @@
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.settings-header h1 {e
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
.settings-header h1 {
|
||||
margin: 0;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@@ -32,11 +27,18 @@
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Main layout grid for settings */
|
||||
.settings-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 2rem; /* Kept for spacing when grid stacks */
|
||||
}
|
||||
|
||||
.settings-group legend {
|
||||
@@ -70,36 +72,39 @@
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
/* Use a more standard monospace font stack */
|
||||
font-family: Consolas, 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-text);
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-description code {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
grid-template-columns: 1fr; /* Simplified to single column within a settings group */
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -121,22 +126,23 @@
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
display: inline-block;
|
||||
background: var(--primary-color);
|
||||
background-color: transparent;
|
||||
border-color: var(--border-color);
|
||||
border-width: 1px;
|
||||
color: #ffffff;
|
||||
color: var(--bg-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
padding: 0.65rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.button-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--text-color);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.save-status {
|
||||
@@ -192,3 +198,15 @@
|
||||
.button-danger:hover {
|
||||
background-color: #ff8f8f;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.danger-action {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.button-danger {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
2
static/css/style.css
Normal file → Executable file
2
static/css/style.css
Normal file → Executable file
@@ -18,7 +18,7 @@
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--divider-color: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Page */
|
||||
|
||||
0
static/css/style.old
Normal file → Executable file
0
static/css/style.old
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
0
static/favicon.png
Normal file → Executable file
0
static/favicon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 702 B |
776
static/js/script.js
Normal file → Executable file
776
static/js/script.js
Normal file → Executable file
@@ -1,29 +1,14 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Constants ---
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB chunks
|
||||
|
||||
// Allow server to provide API prefix (e.g. "/api/v1") via window.APP_CONFIG.api_base
|
||||
const API_BASE = (window.APP_CONFIG && window.APP_CONFIG.api_base) ? window.APP_CONFIG.api_base.replace(/\/$/, '') : '';
|
||||
|
||||
function apiUrl(path) {
|
||||
// path may start with or without a leading slash
|
||||
if (!path) return API_BASE || '/';
|
||||
if (path.startsWith('/')) {
|
||||
return `${API_BASE}${path}`;
|
||||
}
|
||||
return `${API_BASE}/${path}`;
|
||||
}
|
||||
|
||||
// --- User Locale and Timezone Detection ---
|
||||
// --- User Locale ---
|
||||
const USER_LOCALE = navigator.language || 'en-US';
|
||||
const USER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const DATETIME_FORMAT_OPTIONS = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: USER_TIMEZONE,
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', timeZone: USER_TIMEZONE,
|
||||
};
|
||||
|
||||
// --- Element Selectors ---
|
||||
@@ -65,32 +50,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const dialogOutputFormatSelect = document.getElementById('dialog-output-format-select');
|
||||
const dialogTtsModelSelect = document.getElementById('dialog-tts-model-select');
|
||||
|
||||
|
||||
// --- State Variables ---
|
||||
let conversionChoices = null;
|
||||
let transcriptionChoices = null;
|
||||
let ttsChoices = null;
|
||||
let dialogConversionChoices = null;
|
||||
let dialogTtsChoices = null;
|
||||
let ttsModelsCache = []; // Cache for formatted TTS models list
|
||||
const activePolls = new Map();
|
||||
let ttsModelsCache = [];
|
||||
let stagedFiles = null;
|
||||
let jobPollerInterval = null; // Polling timer
|
||||
const POLLING_INTERVAL_MS = 1000; // Check for updates every 3 seconds
|
||||
|
||||
// --- Core Functions ---
|
||||
|
||||
function apiUrl(path) {
|
||||
if (!path) return API_BASE || '/';
|
||||
return path.startsWith('/') ? `${API_BASE}${path}` : `${API_BASE}/${path}`;
|
||||
}
|
||||
|
||||
// --- Authentication-aware Fetch Wrapper ---
|
||||
async function authFetch(url, options = {}) {
|
||||
// Normalize URL through apiUrl() if a bare endpoint is provided
|
||||
if (typeof url === 'string' && url.startsWith('/')) {
|
||||
url = apiUrl(url);
|
||||
}
|
||||
|
||||
// Add default options: include credentials and accept JSON by default
|
||||
options = Object.assign({}, options);
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'credentials')) {
|
||||
options.credentials = 'include';
|
||||
}
|
||||
options.headers = options.headers || {};
|
||||
if (!options.headers.Accept) options.headers.Accept = 'application/json';
|
||||
options = { credentials: 'include', ...options };
|
||||
options.headers = { Accept: 'application/json', ...options.headers };
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (response.status === 401) {
|
||||
@@ -101,8 +84,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
// --- Helper Functions ---
|
||||
function formatBytes(bytes, decimals = 1) {
|
||||
if (!+bytes) return '0 Bytes';
|
||||
const k = 1024;
|
||||
@@ -112,22 +93,165 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// --- Chunked Uploading Logic ---
|
||||
async function pollForJobUpdates() {
|
||||
try {
|
||||
const allJobs = await authFetch('/jobs').then(res => res.json());
|
||||
|
||||
const topLevelJobs = [];
|
||||
const childJobs = [];
|
||||
|
||||
allJobs.forEach(job => {
|
||||
if (job.parent_job_id) {
|
||||
childJobs.push(job);
|
||||
} else {
|
||||
topLevelJobs.push(job);
|
||||
}
|
||||
});
|
||||
|
||||
// Render top-level jobs first, then child jobs.
|
||||
// The renderJobRow function handles both creating and updating rows.
|
||||
topLevelJobs.forEach(job => renderJobRow(job));
|
||||
childJobs.forEach(job => renderJobRow(job));
|
||||
|
||||
// Stop polling if there are no more active jobs.
|
||||
const hasActiveJobs = allJobs.some(job => ['pending', 'processing', 'uploading'].includes(job.status));
|
||||
if (!hasActiveJobs) {
|
||||
stopJobPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Job polling failed:", error);
|
||||
// Don't stop polling on error, just log it and retry next interval.
|
||||
}
|
||||
}
|
||||
|
||||
function startJobPolling() {
|
||||
if (jobPollerInterval) return; // Poller is already running
|
||||
// Run once after a short delay, then start the regular interval
|
||||
setTimeout(pollForJobUpdates, 1000);
|
||||
jobPollerInterval = setInterval(pollForJobUpdates, POLLING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopJobPolling() {
|
||||
if (jobPollerInterval) {
|
||||
clearInterval(jobPollerInterval);
|
||||
jobPollerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobRow(job) {
|
||||
const permanentDomId = `job-${job.id}`;
|
||||
let row = document.getElementById(permanentDomId);
|
||||
|
||||
// --- Generate Content ---
|
||||
let taskTypeLabel = job.task_type;
|
||||
if (job.task_type === 'conversion' && job.processed_filepath) {
|
||||
const extension = job.processed_filepath.split('.').pop();
|
||||
taskTypeLabel = `Convert to ${extension.toUpperCase()}`;
|
||||
} else if (job.task_type === 'academic_pandoc') {
|
||||
taskTypeLabel = 'Academic PDF';
|
||||
} else if (job.task_type === 'tts') {
|
||||
taskTypeLabel = 'Synthesize Speech';
|
||||
} else if (job.task_type === 'unzip') {
|
||||
taskTypeLabel = 'Unpack ZIP';
|
||||
} else if (job.task_type) {
|
||||
taskTypeLabel = job.task_type.charAt(0).toUpperCase() + job.task_type.slice(1);
|
||||
}
|
||||
const formattedDate = new Date(job.created_at).toLocaleString(USER_LOCALE, DATETIME_FORMAT_OPTIONS);
|
||||
let statusHtml = `<span class="job-status-badge status-${job.status}">${job.status}</span>`;
|
||||
if ((job.status === 'processing' || job.status === 'pending') && job.task_type === 'unzip') {
|
||||
statusHtml += `<div class="progress-bar-container"><div class="progress-bar" style="width: ${job.progress || 0}%"></div></div>`;
|
||||
} else if (job.status === 'processing') {
|
||||
const progressClass = (job.progress > 0) ? '' : 'indeterminate';
|
||||
const progressWidth = (job.progress > 0) ? job.progress : 100;
|
||||
statusHtml += `<div class="progress-bar-container"><div class="progress-bar ${progressClass}" style="width: ${progressWidth}%"></div></div>`;
|
||||
}
|
||||
let actionHtml = '<span>-</span>';
|
||||
if (['pending', 'processing', 'uploading'].includes(job.status)) {
|
||||
actionHtml = `<button class="cancel-button" data-job-id="${job.id}">Cancel</button>`;
|
||||
} else if (job.status === 'completed') {
|
||||
if (job.task_type === 'unzip') {
|
||||
actionHtml = `<a href="${apiUrl('/download/zip-batch')}/${encodeURIComponent(job.id)}" class="download-button" download>Download Batch</a>`;
|
||||
} else if (job.processed_filepath) {
|
||||
const downloadFilename = job.processed_filepath.split(/[\\/]/).pop();
|
||||
actionHtml = `<a href="${apiUrl('/download')}/${encodeURIComponent(downloadFilename)}" class="download-button" download>Download</a>`;
|
||||
}
|
||||
} else if (job.status === 'failed') {
|
||||
const errorTitle = job.error_message ? ` title="${job.error_message.replace(/"/g, '"')}"` : '';
|
||||
actionHtml = `<span class="error-text"${errorTitle}>Failed</span>`;
|
||||
} else if (job.status === 'cancelled') {
|
||||
actionHtml = `<span>Cancelled</span>`;
|
||||
}
|
||||
let fileSizeHtml = job.input_filesize ? formatBytes(job.input_filesize) : '-';
|
||||
if (job.status === 'completed' && job.output_filesize) {
|
||||
fileSizeHtml += ` → ${formatBytes(job.output_filesize)}`;
|
||||
}
|
||||
let checkboxHtml = '';
|
||||
if (job.status === 'completed' && job.processed_filepath && job.task_type !== 'unzip') {
|
||||
checkboxHtml = `<input type="checkbox" class="job-checkbox" value="${job.id}">`;
|
||||
}
|
||||
|
||||
// --- Create or Update logic ---
|
||||
if (row) {
|
||||
// UPDATE an existing row
|
||||
row.querySelector('td[data-label="Select"] .cell-value').innerHTML = checkboxHtml;
|
||||
row.querySelector('td[data-label="File Size"] .cell-value').innerHTML = fileSizeHtml;
|
||||
row.querySelector('td[data-label="Task"] .cell-value').innerHTML = taskTypeLabel;
|
||||
row.querySelector('td[data-label="Status"] .cell-value').innerHTML = statusHtml;
|
||||
row.querySelector('td[data-label="Action"] .cell-value').innerHTML = actionHtml;
|
||||
} else {
|
||||
// CREATE a new row
|
||||
row = document.createElement('tr');
|
||||
row.id = permanentDomId;
|
||||
const escapedFilename = job.original_filename ? job.original_filename.replace(/</g, "<").replace(/>/g, ">") : "No filename";
|
||||
const rowClasses = [];
|
||||
if (job.parent_job_id) rowClasses.push('sub-job');
|
||||
if (job.task_type === 'unzip') rowClasses.push('parent-job');
|
||||
row.className = rowClasses.join(' ');
|
||||
if (job.parent_job_id) row.dataset.parentId = job.parent_job_id;
|
||||
const expanderHtml = job.task_type === 'unzip' ? '<span class="expander-arrow"></span>' : '';
|
||||
|
||||
row.innerHTML = `
|
||||
<td data-label="Select"><span class="cell-value">${checkboxHtml}</span></td>
|
||||
<td data-label="File"><span class="cell-value" title="${escapedFilename}">${expanderHtml}<span class="file-cell-content">${escapedFilename}</span></span></td>
|
||||
<td data-label="File Size"><span class="cell-value">${fileSizeHtml}</span></td>
|
||||
<td data-label="Task"><span class="cell-value">${taskTypeLabel}</span></td>
|
||||
<td data-label="Submitted"><span class="cell-value">${formattedDate}</span></td>
|
||||
<td data-label="Status"><span class="cell-value status-cell-value">${statusHtml}</span></td>
|
||||
<td data-label="Action" class="action-col"><span class="cell-value">${actionHtml}</span></td>
|
||||
`;
|
||||
const parentRow = job.parent_job_id ? document.getElementById(`job-${job.parent_job_id}`) : null;
|
||||
if (parentRow) {
|
||||
parentRow.parentNode.insertBefore(row, parentRow.nextSibling);
|
||||
} else {
|
||||
jobListBody.prepend(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFileInChunks(file, taskType, options = {}) {
|
||||
const uploadId = 'upload-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
|
||||
const preliminaryJob = {
|
||||
id: uploadId,
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
original_filename: file.name,
|
||||
input_filesize: file.size,
|
||||
task_type: taskType,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
renderJobRow(preliminaryJob);
|
||||
// Manually create and insert the temporary "uploading" row.
|
||||
const tempRow = document.createElement('tr');
|
||||
tempRow.id = uploadId;
|
||||
const escapedFilename = file.name.replace(/</g, "<").replace(/>/g, ">");
|
||||
const taskLabel = taskType.charAt(0).toUpperCase() + taskType.slice(1);
|
||||
tempRow.innerHTML = `
|
||||
<td data-label="Select"><span class="cell-value">-</span></td>
|
||||
<td data-label="File"><span class="cell-value" title="${escapedFilename}">${escapedFilename}</span></td>
|
||||
<td data-label="File Size"><span class="cell-value">${formatBytes(file.size)}</span></td>
|
||||
<td data-label="Task"><span class="cell-value">${taskLabel}</span></td>
|
||||
<td data-label="Submitted"><span class="cell-value">${new Date().toLocaleString(USER_LOCALE, DATETIME_FORMAT_OPTIONS)}</span></td>
|
||||
<td data-label="Status"><span class="cell-value status-cell-value">
|
||||
<span class="job-status-badge status-uploading">uploading</span>
|
||||
<div class="progress-bar-container"><div class="progress-bar" style="width: 0%"></div></div>
|
||||
</span></td>
|
||||
<td data-label="Action" class="action-col"><span class="cell-value">-</span></td>
|
||||
`;
|
||||
jobListBody.prepend(tempRow);
|
||||
|
||||
// Upload chunks and update the progress bar directly.
|
||||
for (let chunkNumber = 0; chunkNumber < totalChunks; chunkNumber++) {
|
||||
const start = chunkNumber * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
@@ -138,146 +262,69 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
formData.append('chunk_number', chunkNumber);
|
||||
|
||||
try {
|
||||
const response = await authFetch('/upload/chunk', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chunk upload failed with status: ${response.status}`);
|
||||
}
|
||||
const response = await authFetch('/upload/chunk', { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error(`Chunk upload failed: ${response.statusText}`);
|
||||
const progress = Math.round(((chunkNumber + 1) / totalChunks) * 100);
|
||||
updateUploadProgress(uploadId, progress);
|
||||
tempRow.querySelector('.progress-bar').style.width = `${progress}%`;
|
||||
} catch (error) {
|
||||
console.error(`Error uploading chunk ${chunkNumber} for ${file.name}:`, error);
|
||||
if (error.message !== 'Session expired') {
|
||||
updateJobToFailedState(uploadId, `Upload failed: ${error.message}`);
|
||||
}
|
||||
return;
|
||||
console.error(`Error uploading chunk ${chunkNumber}:`, error);
|
||||
tempRow.querySelector('.status-cell-value').innerHTML = `<span class="job-status-badge status-failed">Upload Failed</span>`;
|
||||
return; // Stop the upload process
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the upload.
|
||||
try {
|
||||
const finalizePayload = {
|
||||
upload_id: uploadId,
|
||||
original_filename: file.name,
|
||||
total_chunks: totalChunks,
|
||||
task_type: taskType,
|
||||
...options
|
||||
};
|
||||
const finalizePayload = { upload_id: uploadId, original_filename: file.name, total_chunks: totalChunks, task_type: taskType, ...options };
|
||||
const finalizeResponse = await authFetch('/upload/finalize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(finalizePayload),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(finalizePayload),
|
||||
});
|
||||
if (!finalizeResponse.ok) {
|
||||
let errorData = {};
|
||||
try { errorData = await finalizeResponse.json(); } catch (e) {}
|
||||
const errorData = await finalizeResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Finalization failed');
|
||||
}
|
||||
const result = await finalizeResponse.json();
|
||||
const uploadRow = document.getElementById(uploadId);
|
||||
if (uploadRow) {
|
||||
uploadRow.id = `job-${result.job_id}`;
|
||||
const statusCell = uploadRow.querySelector('td[data-label="Status"] .cell-value');
|
||||
if (statusCell) {
|
||||
statusCell.innerHTML = `<span class="job-status-badge status-pending">Pending</span>`;
|
||||
}
|
||||
}
|
||||
startPolling(result.job_id);
|
||||
|
||||
tempRow.remove();
|
||||
renderJobRow(result);
|
||||
startJobPolling();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error finalizing upload for ${file.name}:`, error);
|
||||
if (error.message !== 'Session expired') {
|
||||
updateJobToFailedState(uploadId, `Finalization failed: ${error.message}`);
|
||||
}
|
||||
console.error(`Error finalizing upload:`, error);
|
||||
tempRow.querySelector('.status-cell-value').innerHTML = `<span class="job-status-badge status-failed">Finalization Failed</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUploadProgress(uploadId, progress) {
|
||||
const row = document.getElementById(uploadId);
|
||||
if (row) {
|
||||
const progressBar = row.querySelector('.progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateJobToFailedState(jobId, errorMessage) {
|
||||
const row = document.getElementById(jobId);
|
||||
if (row) {
|
||||
const statusCell = row.querySelector('td[data-label="Status"] .cell-value');
|
||||
const actionCell = row.querySelector('td[data-label="Action"] .cell-value');
|
||||
if (statusCell) statusCell.innerHTML = `<span class="job-status-badge status-failed">Failed</span>`;
|
||||
if (actionCell) {
|
||||
const errorTitle = errorMessage ? ` title="${errorMessage.replace(/"/g, '"')}"` : '';
|
||||
actionCell.innerHTML = `<span class="error-text"${errorTitle}>Failed</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Centralized Task Request Handler ---
|
||||
async function handleTaskRequest(taskType) {
|
||||
if (mainFileInput.files.length === 0) {
|
||||
alert('Please choose one or more files first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainFileInput.files.length === 0) return alert('Please choose one or more files first.');
|
||||
const files = Array.from(mainFileInput.files);
|
||||
const options = {};
|
||||
|
||||
if (taskType === 'conversion') {
|
||||
const selectedFormat = conversionChoices.getValue(true);
|
||||
if (!selectedFormat) {
|
||||
alert('Please select a format to convert to.');
|
||||
return;
|
||||
}
|
||||
if (!selectedFormat) return alert('Please select a format to convert to.');
|
||||
options.output_format = selectedFormat;
|
||||
} else if (taskType === 'transcription') {
|
||||
const selectedModel = transcriptionChoices.getValue(true);
|
||||
options.model_size = selectedModel;
|
||||
options.model_size = transcriptionChoices.getValue(true);
|
||||
} else if (taskType === 'tts') {
|
||||
const selectedModel = ttsChoices.getValue(true);
|
||||
if (!selectedModel) {
|
||||
alert('Please select a voice model.');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) return alert('Please select a voice model.');
|
||||
options.model_name = selectedModel;
|
||||
}
|
||||
|
||||
|
||||
// Disable buttons during upload process
|
||||
startConversionBtn.disabled = true;
|
||||
startOcrBtn.disabled = true;
|
||||
startTranscriptionBtn.disabled = true;
|
||||
startTtsBtn.disabled = true;
|
||||
|
||||
const uploadPromises = files.map(file => uploadFileInChunks(file, taskType, options));
|
||||
await Promise.allSettled(uploadPromises);
|
||||
|
||||
// Reset file input and re-enable buttons
|
||||
mainFileInput.value = ''; // Resets the file list
|
||||
[startConversionBtn, startOcrBtn, startTranscriptionBtn, startTtsBtn].forEach(btn => btn.disabled = true);
|
||||
await Promise.allSettled(files.map(file => uploadFileInChunks(file, taskType, options)));
|
||||
mainFileInput.value = '';
|
||||
updateFileName(mainFileInput, mainFileName);
|
||||
startConversionBtn.disabled = false;
|
||||
startOcrBtn.disabled = false;
|
||||
startTranscriptionBtn.disabled = false;
|
||||
startTtsBtn.disabled = false;
|
||||
[startConversionBtn, startOcrBtn, startTranscriptionBtn, startTtsBtn].forEach(btn => btn.disabled = false);
|
||||
}
|
||||
|
||||
|
||||
function setupDragAndDropListeners() {
|
||||
let dragCounter = 0;
|
||||
window.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
document.body.classList.add('dragging');
|
||||
});
|
||||
window.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) document.body.classList.remove('dragging');
|
||||
});
|
||||
window.addEventListener('dragover', (e) => e.preventDefault());
|
||||
window.addEventListener('drop', (e) => {
|
||||
window.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; document.body.classList.add('dragging'); });
|
||||
window.addEventListener('dragleave', e => { e.preventDefault(); dragCounter--; if (dragCounter === 0) document.body.classList.remove('dragging'); });
|
||||
window.addEventListener('dragover', e => e.preventDefault());
|
||||
window.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
document.body.classList.remove('dragging');
|
||||
@@ -292,22 +339,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function showActionDialog() {
|
||||
dialogFileCount.textContent = stagedFiles.length;
|
||||
|
||||
// Setup Conversion Dropdown
|
||||
dialogOutputFormatSelect.innerHTML = mainOutputFormatSelect.innerHTML;
|
||||
if (dialogConversionChoices) dialogConversionChoices.destroy();
|
||||
dialogConversionChoices = new Choices(dialogOutputFormatSelect, {
|
||||
searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select a format...',
|
||||
});
|
||||
|
||||
// Setup TTS Dropdown
|
||||
dialogConversionChoices = new Choices(dialogOutputFormatSelect, { searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select a format...' });
|
||||
if (dialogTtsChoices) dialogTtsChoices.destroy();
|
||||
dialogTtsChoices = new Choices(dialogTtsModelSelect, {
|
||||
searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select a voice...',
|
||||
});
|
||||
dialogTtsChoices = new Choices(dialogTtsModelSelect, { searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select a voice...' });
|
||||
dialogTtsChoices.setChoices(ttsModelsCache, 'value', 'label', true);
|
||||
|
||||
|
||||
dialogInitialView.style.display = 'grid';
|
||||
dialogConvertView.style.display = 'none';
|
||||
dialogTtsView.style.display = 'none';
|
||||
@@ -317,161 +354,87 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function closeActionDialog() {
|
||||
actionDialog.classList.remove('visible');
|
||||
stagedFiles = null;
|
||||
if (dialogConversionChoices) {
|
||||
dialogConversionChoices.destroy();
|
||||
dialogConversionChoices = null;
|
||||
if (dialogConversionChoices) { dialogConversionChoices.destroy(); dialogConversionChoices = null; }
|
||||
if (dialogTtsChoices) { dialogTtsChoices.destroy(); dialogTtsChoices = null; }
|
||||
}
|
||||
if (dialogTtsChoices) {
|
||||
dialogTtsChoices.destroy();
|
||||
dialogTtsChoices = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialog Button Listeners ---
|
||||
dialogConvertBtn.addEventListener('click', () => {
|
||||
dialogInitialView.style.display = 'none';
|
||||
dialogConvertView.style.display = 'block';
|
||||
});
|
||||
dialogTtsBtn.addEventListener('click', () => {
|
||||
dialogInitialView.style.display = 'none';
|
||||
dialogTtsView.style.display = 'block';
|
||||
});
|
||||
dialogBackBtn.addEventListener('click', () => {
|
||||
dialogInitialView.style.display = 'grid';
|
||||
dialogConvertView.style.display = 'none';
|
||||
});
|
||||
dialogBackTtsBtn.addEventListener('click', () => {
|
||||
dialogInitialView.style.display = 'grid';
|
||||
dialogTtsView.style.display = 'none';
|
||||
});
|
||||
dialogStartConversionBtn.addEventListener('click', () => handleDialogAction('conversion'));
|
||||
dialogStartTtsBtn.addEventListener('click', () => handleDialogAction('tts'));
|
||||
dialogOcrBtn.addEventListener('click', () => handleDialogAction('ocr'));
|
||||
dialogTranscribeBtn.addEventListener('click', () => handleDialogAction('transcription'));
|
||||
dialogCancelBtn.addEventListener('click', closeActionDialog);
|
||||
|
||||
function handleDialogAction(action) {
|
||||
if (!stagedFiles) return;
|
||||
let options = {};
|
||||
if (action === 'conversion') {
|
||||
const selectedFormat = dialogConversionChoices.getValue(true);
|
||||
if (!selectedFormat) {
|
||||
alert('Please select a format to convert to.');
|
||||
return;
|
||||
}
|
||||
if (!selectedFormat) return alert('Please select a format to convert to.');
|
||||
options.output_format = selectedFormat;
|
||||
} else if (action === 'transcription') {
|
||||
options.model_size = mainModelSizeSelect.value;
|
||||
} else if (action === 'tts') {
|
||||
const selectedModel = dialogTtsChoices.getValue(true);
|
||||
if (!selectedModel) {
|
||||
alert('Please select a voice model.');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) return alert('Please select a voice model.');
|
||||
options.model_name = selectedModel;
|
||||
}
|
||||
Array.from(stagedFiles).forEach(file => uploadFileInChunks(file, action, options));
|
||||
closeActionDialog();
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// TTS models loader (robust)
|
||||
// -----------------------
|
||||
async function loadTtsModels() {
|
||||
try {
|
||||
const response = await authFetch('/api/v1/tts-voices');
|
||||
if (!response.ok) throw new Error('Failed to fetch TTS voices.');
|
||||
const voicesData = await response.json();
|
||||
|
||||
// voicesData might be an object map { id: meta } or an array [{ id, name, language, ... }]
|
||||
const voicesData = await authFetch('/api/v1/tts-voices').then(res => res.json());
|
||||
const voicesArray = [];
|
||||
if (Array.isArray(voicesData)) {
|
||||
for (const v of voicesData) {
|
||||
// Accept either { id, name, language } or { voice_id, title, locale }
|
||||
const id = v.id || v.voice_id || v.voice || v.name || null;
|
||||
const name = v.name || v.title || v.display_name || id || 'Unknown';
|
||||
const lang = (v.language && (v.language.name_native || v.language.name)) || v.locale || (id ? id.split(/[_-]/)[0] : 'Unknown');
|
||||
if (id) voicesArray.push({ id, name, lang });
|
||||
}
|
||||
voicesData.forEach(v => {
|
||||
const id = v.id || v.voice_id || v.name;
|
||||
if (id) voicesArray.push({ id, name: v.name || id, lang: (v.language && v.language.name) || v.locale || id.split(/[_-]/)[0] });
|
||||
});
|
||||
} else if (voicesData && typeof voicesData === 'object') {
|
||||
for (const key in voicesData) {
|
||||
if (!Object.prototype.hasOwnProperty.call(voicesData, key)) continue;
|
||||
Object.keys(voicesData).forEach(key => {
|
||||
const v = voicesData[key];
|
||||
const id = v.id || key;
|
||||
const name = v.name || v.title || v.display_name || id;
|
||||
const lang = (v.language && (v.language.name_native || v.language.name)) || v.locale || (id ? id.split(/[_-]/)[0] : 'Unknown');
|
||||
voicesArray.push({ id, name, lang });
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unexpected voices payload');
|
||||
}
|
||||
|
||||
// Group by language
|
||||
const groups = {};
|
||||
for (const v of voicesArray) {
|
||||
const langLabel = v.lang || 'Unknown';
|
||||
if (!groups[langLabel]) {
|
||||
groups[langLabel] = { label: langLabel, id: langLabel, disabled: false, choices: [] };
|
||||
}
|
||||
groups[langLabel].choices.push({
|
||||
value: v.id,
|
||||
label: `${v.name}`
|
||||
voicesArray.push({ id, name: v.name || id, lang: (v.language && v.language.name) || v.locale || id.split(/[_-]/)[0] });
|
||||
});
|
||||
}
|
||||
ttsModelsCache = Object.values(groups).sort((a,b) => a.label.localeCompare(b.label));
|
||||
// If ttsChoices exists, update it; otherwise the initializer will set choices
|
||||
if (ttsChoices) {
|
||||
ttsChoices.setChoices(ttsModelsCache, 'value', 'label', true);
|
||||
}
|
||||
const groups = voicesArray.reduce((acc, v) => {
|
||||
const langLabel = v.lang || 'Unknown';
|
||||
if (!acc[langLabel]) acc[langLabel] = { label: langLabel, choices: [] };
|
||||
acc[langLabel].choices.push({ value: v.id, label: v.name });
|
||||
return acc;
|
||||
}, {});
|
||||
ttsModelsCache = Object.values(groups).sort((a, b) => a.label.localeCompare(b.label));
|
||||
if (ttsChoices) ttsChoices.setChoices(ttsModelsCache, 'value', 'label', true);
|
||||
} catch (error) {
|
||||
console.error("Couldn't load TTS voices:", error);
|
||||
if (error.message !== 'Session expired') {
|
||||
if (ttsChoices) {
|
||||
ttsChoices.setChoices([{ value: '', label: 'Error loading voices', disabled: true }], 'value', 'label');
|
||||
}
|
||||
}
|
||||
if (ttsChoices && error.message !== 'Session expired') ttsChoices.setChoices([{ value: '', label: 'Error loading voices', disabled: true }], 'value', 'label');
|
||||
}
|
||||
}
|
||||
|
||||
function initializeSelectors() {
|
||||
function initializeSelectors() {
|
||||
if (conversionChoices) conversionChoices.destroy();
|
||||
conversionChoices = new Choices(mainOutputFormatSelect, {
|
||||
searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select a format...',
|
||||
});
|
||||
conversionChoices = new Choices(mainOutputFormatSelect, { searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select a format...' });
|
||||
const tools = window.APP_CONFIG.conversionTools || {};
|
||||
const choicesArray = [];
|
||||
for (const toolKey in tools) {
|
||||
const choicesArray = Object.keys(tools).map(toolKey => {
|
||||
const tool = tools[toolKey];
|
||||
const group = { label: tool.name, id: toolKey, disabled: false, choices: [] };
|
||||
for (const formatKey in tool.formats) {
|
||||
group.choices.push({ value: `${toolKey}_${formatKey}`, label: `${tool.name} - ${formatKey.toUpperCase()} (${tool.formats[formatKey]})` });
|
||||
}
|
||||
choicesArray.push(group);
|
||||
}
|
||||
return {
|
||||
label: tool.name,
|
||||
choices: Object.keys(tool.formats).map(formatKey => ({
|
||||
value: `${toolKey}_${formatKey}`,
|
||||
// --- THIS IS THE MODIFIED LINE ---
|
||||
label: `${tool.name} - ${tool.formats[formatKey]}`
|
||||
}))
|
||||
};
|
||||
});
|
||||
conversionChoices.setChoices(choicesArray, 'value', 'label', true);
|
||||
|
||||
if (transcriptionChoices) transcriptionChoices.destroy();
|
||||
transcriptionChoices = new Choices(mainModelSizeSelect, {
|
||||
searchEnabled: false, shouldSort: false, itemSelectText: '',
|
||||
});
|
||||
transcriptionChoices = new Choices(mainModelSizeSelect, { searchEnabled: false, shouldSort: false, itemSelectText: '' });
|
||||
|
||||
if (ttsChoices) ttsChoices.destroy();
|
||||
ttsChoices = new Choices(mainTtsModelSelect, {
|
||||
searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select voice...',
|
||||
});
|
||||
ttsChoices = new Choices(mainTtsModelSelect, { searchEnabled: true, itemSelectText: 'Select', shouldSort: false, placeholder: true, placeholderValue: 'Select voice...' });
|
||||
loadTtsModels();
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileName(input, nameDisplay) {
|
||||
const numFiles = input.files.length;
|
||||
let displayText = numFiles === 1 ? input.files[0].name : `${numFiles} files selected`;
|
||||
let displayTitle = numFiles > 1 ? Array.from(input.files).map(f => f.name).join(', ') : displayText;
|
||||
if (numFiles === 0) {
|
||||
displayText = 'No file chosen';
|
||||
displayTitle = 'No file chosen';
|
||||
}
|
||||
nameDisplay.textContent = displayText;
|
||||
nameDisplay.title = displayTitle;
|
||||
nameDisplay.textContent = numFiles === 1 ? input.files[0].name : (numFiles > 1 ? `${numFiles} files selected` : 'No files chosen');
|
||||
nameDisplay.title = numFiles > 1 ? Array.from(input.files).map(f => f.name).join(', ') : nameDisplay.textContent;
|
||||
}
|
||||
|
||||
async function handleCancelJob(jobId) {
|
||||
@@ -479,20 +442,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
const response = await authFetch(`/job/${jobId}/cancel`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
let errorData = {};
|
||||
try { errorData = await response.json(); } catch (e) {}
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to cancel job.');
|
||||
}
|
||||
stopPolling(jobId);
|
||||
const row = document.getElementById(`job-${jobId}`);
|
||||
if (row) {
|
||||
const statusCell = row.querySelector('td[data-label="Status"] .cell-value');
|
||||
const actionCell = row.querySelector('td[data-label="Action"] .cell-value');
|
||||
if (statusCell) statusCell.innerHTML = `<span class="job-status-badge status-cancelled">Cancelled</span>`;
|
||||
if (actionCell) actionCell.innerHTML = `<span>-</span>`;
|
||||
}
|
||||
// Trigger a poll soon to see the "cancelled" status updated in the UI.
|
||||
setTimeout(pollForJobUpdates, 500);
|
||||
} catch (error) {
|
||||
console.error('Error cancelling job:', error);
|
||||
if (error.message !== 'Session expired') alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -500,34 +455,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function handleSelectionChange() {
|
||||
const selectedCheckboxes = jobListBody.querySelectorAll('.job-checkbox:checked');
|
||||
downloadSelectedBtn.disabled = selectedCheckboxes.length === 0;
|
||||
|
||||
const allCheckboxes = jobListBody.querySelectorAll('.job-checkbox');
|
||||
selectAllJobsCheckbox.checked = allCheckboxes.length > 0 && selectedCheckboxes.length === allCheckboxes.length;
|
||||
selectAllJobsCheckbox.checked = jobListBody.querySelectorAll('.job-checkbox').length > 0 && selectedCheckboxes.length === jobListBody.querySelectorAll('.job-checkbox').length;
|
||||
}
|
||||
|
||||
async function handleBatchDownload() {
|
||||
const selectedIds = Array.from(jobListBody.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
downloadSelectedBtn.disabled = true;
|
||||
downloadSelectedBtn.textContent = 'Zipping...';
|
||||
|
||||
try {
|
||||
const response = await authFetch('/download/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ job_ids: selectedIds })
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_ids: selectedIds })
|
||||
});
|
||||
if (!response.ok) throw new Error('Batch download failed.');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `file-wizard-batch-${Date.now()}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Batch download error:", error);
|
||||
@@ -542,242 +490,68 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
const response = await authFetch('/jobs');
|
||||
if (!response.ok) throw new Error('Failed to fetch jobs.');
|
||||
let jobs = await response.json();
|
||||
|
||||
// Sort jobs so parents come before children
|
||||
jobs.sort((a, b) => {
|
||||
if (a.id === b.parent_job_id) return -1;
|
||||
if (b.id === a.parent_job_id) return 1;
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
|
||||
const jobs = await response.json();
|
||||
jobListBody.innerHTML = '';
|
||||
for (const job of jobs.reverse()) {
|
||||
renderJobRow(job);
|
||||
if (['pending', 'processing'].includes(job.status)) startPolling(job.id);
|
||||
}
|
||||
jobs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // Sort descending
|
||||
jobs.reverse().forEach(renderJobRow);
|
||||
handleSelectionChange();
|
||||
startJobPolling();
|
||||
} catch (error) {
|
||||
console.error("Couldn't load job history:", error);
|
||||
if (error.message !== 'Session expired') {
|
||||
jobListBody.innerHTML = '<tr><td colspan="7" style="text-align: center;">Could not load job history.</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Polling and UI Rendering ---
|
||||
async function fetchAndRenderSubJobs(parentJobId) {
|
||||
try {
|
||||
const response = await authFetch('/jobs');
|
||||
if (!response.ok) return;
|
||||
const allJobs = await response.json();
|
||||
const childJobs = allJobs.filter(j => j.parent_job_id === parentJobId);
|
||||
|
||||
for (const childJob of childJobs) {
|
||||
const childRowId = `job-${childJob.id}`;
|
||||
if (!document.getElementById(childRowId)) {
|
||||
renderJobRow(childJob);
|
||||
if (['pending', 'processing'].includes(childJob.status)) {
|
||||
startPolling(childJob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch sub-jobs for parent ${parentJobId}:`, error);
|
||||
if (error.message !== 'Session expired') jobListBody.innerHTML = '<tr><td colspan="7" style="text-align: center;">Could not load job history.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(jobId) {
|
||||
if (activePolls.has(jobId)) return;
|
||||
|
||||
const pollLogic = async () => {
|
||||
try {
|
||||
const response = await authFetch(`/job/${jobId}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) stopPolling(jobId);
|
||||
return;
|
||||
}
|
||||
const job = await response.json();
|
||||
renderJobRow(job);
|
||||
|
||||
if (job.task_type === 'unzip' && job.status === 'processing') {
|
||||
await fetchAndRenderSubJobs(job.id);
|
||||
}
|
||||
|
||||
if (['completed', 'failed', 'cancelled'].includes(job.status)) {
|
||||
stopPolling(jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error polling for job ${jobId}:`, error);
|
||||
stopPolling(jobId);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(pollLogic, 3000);
|
||||
activePolls.set(jobId, intervalId);
|
||||
pollLogic(); // Run once immediately
|
||||
}
|
||||
|
||||
|
||||
function stopPolling(jobId) {
|
||||
if (activePolls.has(jobId)) {
|
||||
clearInterval(activePolls.get(jobId));
|
||||
activePolls.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobRow(job) {
|
||||
const rowId = job.id && String(job.id).startsWith('upload-') ? job.id : `job-${job.id}`;
|
||||
let row = document.getElementById(rowId);
|
||||
if (!row) {
|
||||
row = document.createElement('tr');
|
||||
row.id = rowId;
|
||||
const parentRow = job.parent_job_id ? document.getElementById(`job-${job.parent_job_id}`) : null;
|
||||
if (parentRow) {
|
||||
parentRow.parentNode.insertBefore(row, parentRow.nextSibling);
|
||||
} else {
|
||||
jobListBody.prepend(row);
|
||||
}
|
||||
}
|
||||
|
||||
let taskTypeLabel = job.task_type;
|
||||
if (job.task_type === 'conversion' && job.processed_filepath) {
|
||||
const extension = job.processed_filepath.split('.').pop();
|
||||
taskTypeLabel = `Convert to ${extension.toUpperCase()}`;
|
||||
} else if (job.task_type === 'tts') {
|
||||
taskTypeLabel = 'Synthesize Speech';
|
||||
} else if (job.task_type === 'unzip') {
|
||||
taskTypeLabel = 'Unpack ZIP';
|
||||
} else if (job.task_type) {
|
||||
taskTypeLabel = job.task_type.charAt(0).toUpperCase() + job.task_type.slice(1);
|
||||
}
|
||||
|
||||
const submittedDate = job.created_at ? new Date(job.created_at) : new Date();
|
||||
const formattedDate = submittedDate.toLocaleString(USER_LOCALE, DATETIME_FORMAT_OPTIONS);
|
||||
|
||||
let statusHtml = `<span class="job-status-badge status-${job.status}">${job.status}</span>`;
|
||||
if (job.status === 'uploading' || (job.status === 'processing' && job.task_type === 'unzip')) {
|
||||
statusHtml += `<div class="progress-bar-container"><div class="progress-bar" style="width: ${job.progress || 0}%"></div></div>`;
|
||||
} else if (job.status === 'processing') {
|
||||
const progressClass = (job.progress > 0) ? '' : 'indeterminate';
|
||||
const progressWidth = (job.progress > 0) ? job.progress : 100;
|
||||
statusHtml += `<div class="progress-bar-container"><div class="progress-bar ${progressClass}" style="width: ${progressWidth}%"></div></div>`;
|
||||
}
|
||||
|
||||
let actionHtml = `<span>-</span>`;
|
||||
if (['pending', 'processing', 'uploading'].includes(job.status)) {
|
||||
actionHtml = `<button class="cancel-button" data-job-id="${job.id}">Cancel</button>`;
|
||||
} else if (job.status === 'completed') {
|
||||
if (job.task_type === 'unzip') {
|
||||
actionHtml = `<a href="${apiUrl('/download/zip-batch')}/${encodeURIComponent(job.id)}" class="download-button" download>Download Batch</a>`;
|
||||
} else if (job.processed_filepath) {
|
||||
const downloadFilename = job.processed_filepath.split(/[\\/]/).pop();
|
||||
actionHtml = `<a href="${apiUrl('/download')}/${encodeURIComponent(downloadFilename)}" class="download-button" download>Download</a>`;
|
||||
}
|
||||
} else if (job.status === 'failed') {
|
||||
const errorTitle = job.error_message ? ` title="${job.error_message.replace(/"/g, '"')}"` : '';
|
||||
actionHtml = `<span class="error-text"${errorTitle}>Failed</span>`;
|
||||
}
|
||||
|
||||
let fileSizeHtml = job.input_filesize ? formatBytes(job.input_filesize) : '-';
|
||||
if (job.status === 'completed' && job.output_filesize) {
|
||||
fileSizeHtml += ` → ${formatBytes(job.output_filesize)}`;
|
||||
}
|
||||
|
||||
const escapedFilename = job.original_filename ? job.original_filename.replace(/</g, "<").replace(/>/g, ">") : "No filename";
|
||||
|
||||
let checkboxHtml = '';
|
||||
if (job.status === 'completed' && job.processed_filepath && job.task_type !== 'unzip') {
|
||||
checkboxHtml = `<input type="checkbox" class="job-checkbox" value="${job.id}">`;
|
||||
}
|
||||
|
||||
const rowClasses = [];
|
||||
if(job.parent_job_id) rowClasses.push('sub-job');
|
||||
if(job.task_type === 'unzip') rowClasses.push('parent-job');
|
||||
row.className = rowClasses.join(' ');
|
||||
if (job.parent_job_id) {
|
||||
row.dataset.parentId = job.parent_job_id;
|
||||
}
|
||||
|
||||
const expanderHtml = job.task_type === 'unzip' ? '<span class="expander-arrow"></span>' : '';
|
||||
|
||||
row.innerHTML = `
|
||||
<td data-label="Select"><span class="cell-value">${checkboxHtml}</span></td>
|
||||
<td data-label="File"><span class="cell-value" title="${escapedFilename}">${expanderHtml}<span class="file-cell-content">${escapedFilename}</span></span></td>
|
||||
<td data-label="File Size"><span class="cell-value">${fileSizeHtml}</span></td>
|
||||
<td data-label="Task"><span class="cell-value">${taskTypeLabel}</span></td>
|
||||
<td data-label="Submitted"><span class="cell-value">${formattedDate}</span></td>
|
||||
<td data-label="Status"><span class="cell-value status-cell-value">${statusHtml}</span></td>
|
||||
<td data-label="Action" class="action-col"><span class="cell-value">${actionHtml}</span></td>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- App Initialization and Auth Check ---
|
||||
function initializeApp() {
|
||||
if (appContainer) appContainer.style.display = 'block';
|
||||
if (loginContainer) loginContainer.style.display = 'none';
|
||||
|
||||
// Setup event listeners
|
||||
startConversionBtn.addEventListener('click', () => handleTaskRequest('conversion'));
|
||||
startOcrBtn.addEventListener('click', () => handleTaskRequest('ocr'));
|
||||
startTranscriptionBtn.addEventListener('click', () => handleTaskRequest('transcription'));
|
||||
startTtsBtn.addEventListener('click', () => handleTaskRequest('tts'));
|
||||
mainFileInput.addEventListener('change', () => updateFileName(mainFileInput, mainFileName));
|
||||
|
||||
jobListBody.addEventListener('click', (event) => {
|
||||
if (event.target.classList.contains('cancel-button')) {
|
||||
const jobId = event.target.dataset.jobId;
|
||||
handleCancelJob(jobId);
|
||||
return;
|
||||
downloadSelectedBtn.addEventListener('click', handleBatchDownload);
|
||||
selectAllJobsCheckbox.addEventListener('change', handleSelectionChange);
|
||||
jobListBody.addEventListener('change', e => e.target.classList.contains('job-checkbox') && handleSelectionChange());
|
||||
jobListBody.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('cancel-button')) {
|
||||
e.preventDefault();
|
||||
handleCancelJob(e.target.dataset.jobId);
|
||||
}
|
||||
// Event delegation for collapsible rows
|
||||
const parentRow = event.target.closest('tr.parent-job');
|
||||
if (parentRow) {
|
||||
const parentId = parentRow.id.replace('job-', '');
|
||||
const parentRow = e.target.closest('tr.parent-job');
|
||||
if (parentRow && !e.target.classList.contains('cancel-button') && !e.target.classList.contains('download-button')) {
|
||||
parentRow.classList.toggle('sub-jobs-visible');
|
||||
const areVisible = parentRow.classList.contains('sub-jobs-visible');
|
||||
|
||||
// Toggle visibility of all child job rows
|
||||
const subJobs = jobListBody.querySelectorAll(`tr.sub-job[data-parent-id="${parentId}"]`);
|
||||
subJobs.forEach(subJob => {
|
||||
// Use classes instead of direct style manipulation for robustness
|
||||
if (areVisible) {
|
||||
subJob.classList.add('is-visible');
|
||||
} else {
|
||||
subJob.classList.remove('is-visible');
|
||||
}
|
||||
jobListBody.querySelectorAll(`tr.sub-job[data-parent-id="${parentRow.id.replace('job-', '')}"]`)
|
||||
.forEach(subJob => {
|
||||
subJob.style.display = areVisible ? 'table-row' : 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
jobListBody.addEventListener('change', (event) => {
|
||||
if (event.target.classList.contains('job-checkbox')) {
|
||||
handleSelectionChange();
|
||||
}
|
||||
});
|
||||
// Dialog listeners
|
||||
dialogConvertBtn.addEventListener('click', () => { dialogInitialView.style.display = 'none'; dialogConvertView.style.display = 'block'; });
|
||||
dialogTtsBtn.addEventListener('click', () => { dialogInitialView.style.display = 'none'; dialogTtsView.style.display = 'block'; });
|
||||
dialogBackBtn.addEventListener('click', () => { dialogInitialView.style.display = 'grid'; dialogConvertView.style.display = 'none'; });
|
||||
dialogBackTtsBtn.addEventListener('click', () => { dialogInitialView.style.display = 'grid'; dialogTtsView.style.display = 'none'; });
|
||||
dialogStartConversionBtn.addEventListener('click', () => handleDialogAction('conversion'));
|
||||
dialogStartTtsBtn.addEventListener('click', () => handleDialogAction('tts'));
|
||||
dialogOcrBtn.addEventListener('click', () => handleDialogAction('ocr'));
|
||||
dialogTranscribeBtn.addEventListener('click', () => handleDialogAction('transcription'));
|
||||
dialogCancelBtn.addEventListener('click', closeActionDialog);
|
||||
|
||||
selectAllJobsCheckbox.addEventListener('change', () => {
|
||||
const isChecked = selectAllJobsCheckbox.checked;
|
||||
jobListBody.querySelectorAll('.job-checkbox').forEach(cb => {
|
||||
cb.checked = isChecked;
|
||||
});
|
||||
handleSelectionChange();
|
||||
});
|
||||
downloadSelectedBtn.addEventListener('click', handleBatchDownload);
|
||||
|
||||
// Load initial data and setup UI components
|
||||
// Initialize UI
|
||||
initializeSelectors();
|
||||
loadInitialJobs();
|
||||
setupDragAndDropListeners();
|
||||
}
|
||||
|
||||
function showLoginView() {
|
||||
if (appContainer) appContainer.style.display = 'none';
|
||||
if (loginContainer) loginContainer.style.display = 'flex';
|
||||
if (loginButton) {
|
||||
loginButton.addEventListener('click', () => {
|
||||
window.location.href = apiUrl('/login');
|
||||
});
|
||||
}
|
||||
if (appContainer) appContainer.style.display = 'block';
|
||||
if (loginContainer) loginContainer.style.display = 'none';
|
||||
if (loginButton) loginButton.addEventListener('click', () => { window.location.href = apiUrl('/login'); });
|
||||
}
|
||||
|
||||
// --- Entry Point ---
|
||||
@@ -786,4 +560,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} else {
|
||||
showLoginView();
|
||||
}
|
||||
});
|
||||
})
|
||||
0
static/js/script.old
Normal file → Executable file
0
static/js/script.old
Normal file → Executable file
46
static/js/settings.js
Normal file → Executable file
46
static/js/settings.js
Normal file → Executable file
@@ -11,41 +11,45 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
saveStatus.textContent = 'Saving...';
|
||||
saveStatus.classList.remove('success', 'error');
|
||||
|
||||
const formData = new FormData(settingsForm);
|
||||
const settingsObject = {};
|
||||
const elements = Array.from(settingsForm.elements);
|
||||
|
||||
// Convert FormData to a nested object
|
||||
formData.forEach((value, key) => {
|
||||
// Handle checkboxes that might not be submitted if unchecked
|
||||
if (key.includes('ocr_settings')) {
|
||||
const checkbox = document.querySelector(`[name="${key}"]`);
|
||||
if (checkbox && checkbox.type === 'checkbox') {
|
||||
value = checkbox.checked;
|
||||
for (const el of elements) {
|
||||
if (!el.name || el.type === 'submit') continue; // Skip elements without a name and submit buttons
|
||||
|
||||
let value;
|
||||
const keys = el.name.split('.');
|
||||
|
||||
// Determine value based on element type
|
||||
if (el.type === 'checkbox') {
|
||||
value = el.checked;
|
||||
} else if (el.tagName === 'TEXTAREA') {
|
||||
// Convert comma-separated text into an array of strings
|
||||
value = el.value.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(item => item); // Remove empty strings from the list
|
||||
} else if (el.type === 'number') {
|
||||
value = parseFloat(el.value);
|
||||
if (isNaN(value)) {
|
||||
value = null; // Represent empty number fields as null
|
||||
}
|
||||
} else {
|
||||
value = el.value;
|
||||
}
|
||||
|
||||
const keys = key.split('.');
|
||||
// Build nested object from dot-notation name
|
||||
let current = settingsObject;
|
||||
keys.forEach((k, index) => {
|
||||
if (index === keys.length - 1) {
|
||||
current[k] = value;
|
||||
} else {
|
||||
current[k] = current[k] || {};
|
||||
if (!current[k]) {
|
||||
current[k] = {};
|
||||
}
|
||||
current = current[k];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure unchecked OCR boxes are sent as false
|
||||
const ocrCheckboxes = settingsForm.querySelectorAll('input[type="checkbox"][name^="ocr_settings"]');
|
||||
ocrCheckboxes.forEach(cb => {
|
||||
const keys = cb.name.split('.');
|
||||
if (!formData.has(cb.name)) {
|
||||
// this is a bit of a hack but gets the job done for this specific form
|
||||
settingsObject[keys[0]][keys[1]][keys[2]] = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/save', {
|
||||
|
||||
0
supervisor.conf
Normal file → Executable file
0
supervisor.conf
Normal file → Executable file
0
swappy-20250920_155526.png
Normal file → Executable file
0
swappy-20250920_155526.png
Normal file → Executable file
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 358 KiB |
0
templates/index.html
Normal file → Executable file
0
templates/index.html
Normal file → Executable file
0
templates/index.old
Normal file → Executable file
0
templates/index.old
Normal file → Executable file
102
templates/settings.html
Normal file → Executable file
102
templates/settings.html
Normal file → Executable file
@@ -6,9 +6,7 @@
|
||||
<title>Settings - File Wizard</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -21,15 +19,45 @@
|
||||
|
||||
<main>
|
||||
<form id="settings-form">
|
||||
|
||||
<div class="settings-main-grid">
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>General Settings</h2></legend>
|
||||
<div class="form-control">
|
||||
<label for="app-public-url">App Public URL</label>
|
||||
<p class="field-description">The public-facing base URL of the application (e.g., https://files.example.com). Used for generating absolute URLs in webhooks.</p>
|
||||
<input type="text" id="app-public-url" name="app_settings.app_public_url" value="{{ config.app_settings.get('app_public_url', '') }}" class="form-input" placeholder="https://... ">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="app-max-file-size">Max Upload Size (MB)</label>
|
||||
<input type="number" id="app-max-file-size" name="app_settings.max_file_size_mb" value="{{ config.app_settings.max_file_size_mb }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="app-allowed-extensions">Allowed File Extensions for Conversion</label>
|
||||
<p class="field-description">A comma-separated list of file extensions (e.g., .pdf, .docx, .png). If empty, all files are allowed.</p>
|
||||
<textarea id="app-allowed-extensions" name="app_settings.allowed_all_extensions" class="form-textarea" rows="2">{{ config.app_settings.get('allowed_all_extensions', []) | join(', ') }}</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>Performance Tuning</h2></legend>
|
||||
<div class="form-control">
|
||||
<label for="perf-model-concurrency">Model Concurrency Limit</label>
|
||||
<p class="field-description">Maximum number of AI models (e.g., Piper TTS) that can run in parallel. Helps prevent CPU/GPU overload.</p>
|
||||
<input type="number" id="perf-model-concurrency" name="app_settings.model_concurrency" value="{{ config.app_settings.get('model_concurrency', 1) }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="perf-model-timeout">Model Inactivity Timeout (seconds)</label>
|
||||
<p class="field-description">Time in seconds before an unused Whisper model is unloaded from memory.</p>
|
||||
<input type="number" id="perf-model-timeout" name="app_settings.model_inactivity_timeout" value="{{ config.app_settings.get('model_inactivity_timeout', 1800) }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="perf-cache-interval">Cache Check Interval (seconds)</label>
|
||||
<p class="field-description">How often to check for inactive models to unload.</p>
|
||||
<input type="number" id="perf-cache-interval" name="app_settings.cache_check_interval" value="{{ config.app_settings.get('cache_check_interval', 300) }}" class="form-input">
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>OCR (ocrmypdf)</h2></legend>
|
||||
<div class="form-control checkbox-group">
|
||||
@@ -48,6 +76,7 @@
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>Transcription (Whisper)</h2></legend>
|
||||
<p class="field-description">Device settings (CPU/GPU) are configured via environment variables (see documentation).</p>
|
||||
<div class="form-control">
|
||||
<label for="whisper-compute-type">Compute Type</label>
|
||||
<select id="whisper-compute-type" name="transcription_settings.whisper.compute_type" class="form-select">
|
||||
@@ -58,6 +87,71 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>Authentication (OIDC)</h2></legend>
|
||||
<p class="field-description">Used for logging in users. Requires `LOCAL_ONLY=False` environment variable.</p>
|
||||
<div class="form-control">
|
||||
<label for="auth-client-id">Client ID</label>
|
||||
<input type="text" id="auth-client-id" name="auth_settings.oidc_client_id" value="{{ config.auth_settings.get('oidc_client_id', '') }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="auth-client-secret">Client Secret</label>
|
||||
<input type="password" id="auth-client-secret" name="auth_settings.oidc_client_secret" value="{{ config.auth_settings.get('oidc_client_secret', '') }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="auth-metadata-url">Server Metadata URL</label>
|
||||
<input type="text" id="auth-metadata-url" name="auth_settings.oidc_server_metadata_url" value="{{ config.auth_settings.get('oidc_server_metadata_url', '') }}" class="form-input" placeholder="https://your-auth-server/.well-known/openid-configuration">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="auth-admin-users">Admin User Emails</label>
|
||||
<p class="field-description">Comma-separated list of email addresses for users who should have admin rights.</p>
|
||||
<textarea id="auth-admin-users" name="auth_settings.admin_users" class="form-textarea" rows="2">{{ config.auth_settings.get('admin_users', []) | join(', ') }}</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>Webhooks</h2></legend>
|
||||
<p class="field-description">Allow programmatic access and job status callbacks.</p>
|
||||
<div class="form-control checkbox-group">
|
||||
<input type="checkbox" id="webhook-enabled" name="webhook_settings.enabled" {% if config.webhook_settings.get('enabled') %}checked{% endif %}>
|
||||
<label for="webhook-enabled">Enable Webhook API</label>
|
||||
</div>
|
||||
<div class="form-control checkbox-group">
|
||||
<input type="checkbox" id="webhook-chunked-uploads" name="webhook_settings.allow_chunked_api_uploads" {% if config.webhook_settings.get('allow_chunked_api_uploads') %}checked{% endif %}>
|
||||
<label for="webhook-chunked-uploads">Allow Chunked Uploads via API</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="webhook-allowed-urls">Allowed Callback URLs</label>
|
||||
<p class="field-description">Comma-separated list of URLs or domain prefixes that are allowed for callbacks (e.g., https://n8n.example.com).</p>
|
||||
<textarea id="webhook-allowed-urls" name="webhook_settings.allowed_callback_urls" class="form-textarea" rows="2">{{ config.webhook_settings.get('allowed_callback_urls', []) | join(', ') }}</textarea>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="webhook-token">Callback Bearer Token</label>
|
||||
<p class="field-description">If set, this token will be sent in the `Authorization` header for all callback requests.</p>
|
||||
<input type="password" id="webhook-token" name="webhook_settings.callback_bearer_token" value="{{ config.webhook_settings.get('callback_bearer_token', '') }}" class="form-input">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>TTS (Piper)</h2></legend>
|
||||
<div class="form-control checkbox-group">
|
||||
<input type="checkbox" id="tts-piper-cuda" name="tts_settings.piper.use_cuda" {% if config.tts_settings.piper.get('use_cuda') %}checked{% endif %}>
|
||||
<label for="tts-piper-cuda">Use CUDA (GPU)</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="tts-piper-length">Length Scale</label>
|
||||
<input type="number" step="0.1" id="tts-piper-length" name="tts_settings.piper.synthesis_config.length_scale" value="{{ config.tts_settings.piper.synthesis_config.get('length_scale', 1.0) }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="tts-piper-noise">Noise Scale</label>
|
||||
<input type="number" step="0.1" id="tts-piper-noise" name="tts_settings.piper.synthesis_config.noise_scale" value="{{ config.tts_settings.piper.synthesis_config.get('noise_scale', 0.667) }}" class="form-input">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="tts-piper-noise-w">Noise W</label>
|
||||
<input type="number" step="0.1" id="tts-piper-noise-w" name="tts_settings.piper.synthesis_config.noise_w" value="{{ config.tts_settings.piper.synthesis_config.get('noise_w', 0.8) }}" class="form-input">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><h2>Conversion Tools</h2></legend>
|
||||
<p class="field-description">
|
||||
|
||||
Reference in New Issue
Block a user