acadmic texts

This commit is contained in:
Manuel
2025-09-23 20:01:52 +02:00
parent 2658a71651
commit 918889e6df
24 changed files with 1040 additions and 867 deletions

0
.dockerignore Normal file → Executable file
View File

0
.env.example Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

1
Dockerfile Normal file → Executable file
View 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
LICENSE Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
docker-compose.yml Normal file → Executable file
View File

756
main.py Normal file → Executable file

File diff suppressed because it is too large Load Diff

0
requirements.txt Normal file → Executable file
View File

2
run.sh
View File

@@ -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
View 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
View 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
View 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
View File

0
static/favicon.ico Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

0
static/favicon.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 702 B

770
static/js/script.js Normal file → Executable file
View 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, '&quot;')}"` : '';
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, "&lt;").replace(/>/g, "&gt;") : "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, "&lt;").replace(/>/g, "&gt;");
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, '&quot;')}"` : '';
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] });
});
}
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 exists, update it; otherwise the initializer will set choices
if (ttsChoices) {
ttsChoices.setChoices(ttsModelsCache, 'value', 'label', true);
}
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() {
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, '&quot;')}"` : '';
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, "&lt;").replace(/>/g, "&gt;") : "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
View File

46
static/js/settings.js Normal file → Executable file
View 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
View File

0
swappy-20250920_155526.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 358 KiB

0
templates/index.html Normal file → Executable file
View File

0
templates/index.old Normal file → Executable file
View File

102
templates/settings.html Normal file → Executable file
View 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">