Add FTP/SFTP support and tree-view folder picker with lazy loading

FTP/SFTP processor:
- New ftp_processor.py with adapter pattern for FTP (passive) and SFTP
- Same design as smb_processor: read PDFs, forward via SMTP, move to processed
- Eingangs-/Ausgangsbelege with separate paths, modes (forward/separator)
- paramiko==3.5.0 for SFTP support
- Schema v9 with new ftp_* settings
- Integrated in scheduler

Tree-view folder picker (SMB + FTP):
- Reusable tree rendering from flat path lists
- Expandable/collapsible nodes with toggle arrows
- Lazy loading: only top-level folders on open, sub-folders on-demand
- Auto-expand ancestors of currently selected value (with preload)
- Reload button stays for manual refresh
- Always fresh load when opening picker
- New endpoints: /api/list-smb-subfolders, /api/list-ftp-subfolders

FTP-specific fixes:
- list_pdfs uses LIST instead of NLST (more reliable across servers)
- Stateful CWD bug fixed in ensure_dir/stat_exists/rename
  (previously created /Buch/Buch/X instead of /Buch/X due to CWD drift)
- All operations reset CWD via _reset_cwd() before stateful calls
- _resolve() helper for SFTP to handle empty path / chroot users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 13:26:42 +02:00
parent 35366e0c1b
commit 4b9df132d7
9 changed files with 1287 additions and 28 deletions
+538 -20
View File
@@ -227,6 +227,97 @@
</div>
</div>
<div class="card">
<h2>FTP / SFTP-Server</h2>
<div class="form-grid">
<div class="form-group">
<label for="ftp_enabled">FTP-Import</label>
<select id="ftp_enabled" name="ftp_enabled">
<option value="true" {% if settings.get('ftp_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
<option value="false" {% if settings.get('ftp_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
</select>
</div>
<div class="form-group">
<label for="ftp_protocol">Protokoll</label>
<select id="ftp_protocol" name="ftp_protocol" onchange="updateFtpDefaultPort()">
<option value="sftp" {% if settings.get('ftp_protocol', 'sftp') == 'sftp' %}selected{% endif %}>SFTP (SSH, verschluesselt)</option>
<option value="ftp" {% if settings.get('ftp_protocol') == 'ftp' %}selected{% endif %}>FTP (passiv, unverschluesselt)</option>
</select>
</div>
<div class="form-group">
<label for="ftp_mode">Verarbeitungsmodus</label>
<select id="ftp_mode" name="ftp_mode">
<option value="forward" {% if settings.get('ftp_mode', 'forward') == 'forward' %}selected{% endif %}>Direkt weiterleiten</option>
<option value="separator" {% if settings.get('ftp_mode') == 'separator' %}selected{% endif %}>Trennseiten-Erkennung</option>
</select>
<small class="text-muted">Direkt: jede PDF als ein Beleg. Trennseiten: QR-Splitting wie bei Scan-Upload.</small>
</div>
<div class="form-group">
<label for="ftp_server">Server</label>
<input type="text" id="ftp_server" name="ftp_server"
value="{{ settings.get('ftp_server', '') }}" placeholder="ftp.example.com oder 192.168.1.100">
</div>
<div class="form-group">
<label for="ftp_port">Port</label>
<input type="number" id="ftp_port" name="ftp_port"
value="{{ settings.get('ftp_port', '22') }}">
<small class="text-muted">SFTP=22, FTP=21</small>
</div>
<div class="form-group">
<label for="ftp_username">Benutzername</label>
<input type="text" id="ftp_username" name="ftp_username"
value="{{ settings.get('ftp_username', '') }}" placeholder="user">
</div>
<div class="form-group">
<label for="ftp_password">Passwort</label>
<input type="password" id="ftp_password" name="ftp_password"
placeholder="{% if settings.get('ftp_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
</div>
<div class="form-group">
<label for="ftp_source_path">Quellordner Eingangsbelege</label>
<div class="input-with-btn">
<input type="text" id="ftp_source_path" name="ftp_source_path"
value="{{ settings.get('ftp_source_path', '') }}" placeholder="(Wurzel)">
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_source_path')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="ftp_processed_path">Verarbeitet-Ordner Eingangsbelege</label>
<div class="input-with-btn">
<input type="text" id="ftp_processed_path" name="ftp_processed_path"
value="{{ settings.get('ftp_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_processed_path')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="ftp_source_path_ausgang">Quellordner Ausgangsbelege</label>
<div class="input-with-btn">
<input type="text" id="ftp_source_path_ausgang" name="ftp_source_path_ausgang"
value="{{ settings.get('ftp_source_path_ausgang', '') }}" placeholder="(optional)">
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_source_path_ausgang')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="ftp_processed_path_ausgang">Verarbeitet-Ordner Ausgangsbelege</label>
<div class="input-with-btn">
<input type="text" id="ftp_processed_path_ausgang" name="ftp_processed_path_ausgang"
value="{{ settings.get('ftp_processed_path_ausgang', '') }}" placeholder="(optional)">
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_processed_path_ausgang')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testFtp()">
<span class="btn-text">Verbindung testen &amp; Ordner laden</span>
<span class="btn-spinner" style="display:none;">Verbinde...</span>
</button>
<button type="button" class="btn btn-secondary" onclick="processFtp()">
<span class="btn-text">Jetzt abrufen</span>
<span class="btn-spinner" style="display:none;">Verarbeite...</span>
</button>
</div>
</div>
<div class="card">
<h2>Zeitplan</h2>
<div class="form-grid">
@@ -341,7 +432,10 @@
<div class="modal">
<div class="modal-header">
<h3 id="smbFolderModalTitle">SMB-Ordner auswählen</h3>
<button type="button" class="modal-close" onclick="closeSmbFolderModal()">&times;</button>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="btn btn-secondary btn-sm" onclick="reloadSmbFolders()" title="Ordner-Liste neu laden">&#8634; Neu laden</button>
<button type="button" class="modal-close" onclick="closeSmbFolderModal()">&times;</button>
</div>
</div>
<div class="modal-body">
<div id="smbFolderList" class="folder-list"></div>
@@ -353,12 +447,176 @@
</div>
</div>
<div id="ftpFolderModal" class="modal-overlay" style="display:none;" onclick="closeFtpFolderModal(event)">
<div class="modal">
<div class="modal-header">
<h3 id="ftpFolderModalTitle">FTP-Ordner auswählen</h3>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="btn btn-secondary btn-sm" onclick="reloadFtpFolders()" title="Ordner-Liste neu laden">&#8634; Neu laden</button>
<button type="button" class="modal-close" onclick="closeFtpFolderModal()">&times;</button>
</div>
</div>
<div class="modal-body">
<div id="ftpFolderList" class="folder-list"></div>
<div id="ftpFolderLoading" class="text-muted" style="display:none;padding:1rem;">
Verbinde und lade Ordner...
</div>
<div id="ftpFolderError" class="alert alert-error" style="display:none;margin:1rem;"></div>
</div>
</div>
</div>
<script>
let cachedFolders = null;
let cachedDelimiter = '.';
let folderTargetField = null;
let createOpenFor = null;
// --- Generic Tree-View Helpers (with Lazy Loading) ---
// Used by both SMB and FTP folder pickers
const expandedTreeNodes = { smb: new Set(), ftp: new Set() };
const loadedTreeNodes = { smb: new Set(['']), ftp: new Set([''])}; // root is always loaded
const loadingTreeNodes = { smb: new Set(), ftp: new Set() };
function buildFolderTree(paths) {
// Build nested tree from flat paths like ["A", "A/B", "A/B/C", "X"]
const root = { name: '', path: '', children: {} };
paths.forEach(p => {
if (!p) return;
const parts = p.split('/');
let node = root;
let curPath = '';
for (const part of parts) {
curPath = curPath ? curPath + '/' + part : part;
if (!node.children[part]) {
node.children[part] = { name: part, path: curPath, children: {} };
}
node = node.children[part];
}
});
return root;
}
function renderFolderTree(node, ns, currentValue, selectFn, addBtnFn, depth) {
let html = '';
const childKeys = Object.keys(node.children).sort((a, b) => a.localeCompare(b));
childKeys.forEach(key => {
const child = node.children[key];
const hasChildren = Object.keys(child.children).length > 0;
const isLoaded = loadedTreeNodes[ns].has(child.path);
const isLoading = loadingTreeNodes[ns].has(child.path);
const isExpanded = expandedTreeNodes[ns].has(child.path);
const isSelected = child.path === currentValue;
const escapedPath = child.path.replace(/'/g, "\\'");
// Always show toggle button - we don't know yet if there are children until loaded
const arrow = isLoading ? '&#8987;' // ⌛
: isExpanded ? '&#9660;' // ▼
: '&#9654;'; // ▶
html += '<div class="folder-row">';
html += '<button type="button" class="folder-tree-toggle" onclick="toggleTreeNode(\'' + ns + '\',\'' + escapedPath + '\')">' + arrow + '</button>';
html += '<button type="button" class="folder-item' + (isSelected ? ' selected' : '') + '" onclick="' + selectFn + '(\'' + escapedPath + '\')">';
html += '<span class="folder-icon">&#128193;</span> ' + esc(child.name);
if (isSelected) html += ' <span class="badge badge-success">ausgewählt</span>';
html += '</button>';
html += '<button type="button" class="folder-add-btn" onclick="event.stopPropagation(); ' + addBtnFn + '(\'' + escapedPath + '\')" title="Unterordner erstellen">&#128193;+</button>';
html += '</div>';
html += '<div id="' + ns + '-create-row-' + CSS.escape(child.path) + '" class="create-inline" style="display:none;"></div>';
if (isExpanded && (hasChildren || isLoaded)) {
html += '<div class="folder-tree-children">';
if (hasChildren) {
html += renderFolderTree(child, ns, currentValue, selectFn, addBtnFn, depth + 1);
} else if (isLoaded) {
html += '<p class="text-muted" style="padding:0.25rem 0.5rem;font-size:0.85rem;">(keine Unterordner)</p>';
}
html += '</div>';
}
});
return html;
}
async function toggleTreeNode(ns, path) {
if (expandedTreeNodes[ns].has(path)) {
// Collapse
expandedTreeNodes[ns].delete(path);
if (ns === 'smb') showSmbFolderModal(smbFolderTargetField);
else if (ns === 'ftp') showFtpFolderModal(ftpFolderTargetField);
return;
}
// Expand - load subfolders if not loaded yet
if (!loadedTreeNodes[ns].has(path)) {
loadingTreeNodes[ns].add(path);
if (ns === 'smb') showSmbFolderModal(smbFolderTargetField);
else if (ns === 'ftp') showFtpFolderModal(ftpFolderTargetField);
try {
const endpoint = '/api/list-' + ns + '-subfolders?path=' + encodeURIComponent(path);
const resp = await fetch(endpoint);
const data = await resp.json();
if (data.success && data.folders) {
// Merge new folders into cache
const cacheKey = ns === 'smb' ? 'cachedSmbFolders' : 'cachedFtpFolders';
const existingSet = new Set(window[cacheKey] || []);
data.folders.forEach(f => existingSet.add(f));
window[cacheKey] = Array.from(existingSet).sort();
loadedTreeNodes[ns].add(path);
}
} catch (e) {
console.error('Lazy load failed:', e);
} finally {
loadingTreeNodes[ns].delete(path);
}
}
expandedTreeNodes[ns].add(path);
if (ns === 'smb') showSmbFolderModal(smbFolderTargetField);
else if (ns === 'ftp') showFtpFolderModal(ftpFolderTargetField);
}
function expandTreePathsForValue(ns, value) {
if (!value) return;
const parts = value.split('/');
let cur = '';
for (let i = 0; i < parts.length - 1; i++) {
cur = cur ? cur + '/' + parts[i] : parts[i];
expandedTreeNodes[ns].add(cur);
}
}
async function preloadTreePathForValue(ns, value) {
// Lazy-load all ancestor paths so the tree displays the selected value
if (!value) return;
const parts = value.split('/');
let cur = '';
const cacheKey = ns === 'smb' ? 'cachedSmbFolders' : 'cachedFtpFolders';
for (let i = 0; i < parts.length - 1; i++) {
cur = cur ? cur + '/' + parts[i] : parts[i];
if (loadedTreeNodes[ns].has(cur)) continue;
try {
const endpoint = '/api/list-' + ns + '-subfolders?path=' + encodeURIComponent(cur);
const resp = await fetch(endpoint);
const data = await resp.json();
if (data.success && data.folders) {
const existingSet = new Set(window[cacheKey] || []);
data.folders.forEach(f => existingSet.add(f));
window[cacheKey] = Array.from(existingSet).sort();
loadedTreeNodes[ns].add(cur);
}
} catch (e) { /* ignore */ }
}
}
function resetTreeState(ns) {
expandedTreeNodes[ns].clear();
loadedTreeNodes[ns].clear();
loadedTreeNodes[ns].add('');
loadingTreeNodes[ns].clear();
}
function showAlert(message, type) {
const el = document.getElementById('jsAlert');
el.textContent = message;
@@ -682,12 +940,19 @@ async function testSmb() {
function openSmbFolderPicker(targetField) {
smbFolderTargetField = targetField;
if (cachedSmbFolders) {
showSmbFolderModal(targetField);
} else {
showSmbFolderModalLoading(targetField);
fetchSmbFolders(targetField);
}
// Always reload when opening to ensure fresh state
cachedSmbFolders = null;
window.cachedSmbFolders = null;
resetTreeState('smb');
showSmbFolderModalLoading(targetField);
fetchSmbFolders(targetField);
}
function reloadSmbFolders() {
cachedSmbFolders = null;
resetTreeState('smb');
showSmbFolderModalLoading(smbFolderTargetField);
fetchSmbFolders(smbFolderTargetField);
}
async function fetchSmbFolders(targetField) {
@@ -696,6 +961,12 @@ async function fetchSmbFolders(targetField) {
const data = await resp.json();
if (data.success) {
cachedSmbFolders = data.folders;
window.cachedSmbFolders = data.folders;
// Preload ancestor paths if a value is already selected
if (targetField) {
const currentValue = document.getElementById(targetField).value;
await preloadTreePathForValue('smb', currentValue);
}
showSmbFolderModal(targetField);
} else {
showSmbFolderModalError('SMB-Verbindung fehlgeschlagen: ' + data.error);
@@ -741,19 +1012,12 @@ function showSmbFolderModal(targetField) {
html += '</div>';
}
if (cachedSmbFolders && cachedSmbFolders.length > 0) {
cachedSmbFolders.forEach(folder => {
const isSelected = folder === currentValue;
const escapedFolder = folder.replace(/'/g, "\\'");
html += '<div class="folder-row">';
html += '<button type="button" class="folder-item' + (isSelected ? ' selected' : '') + '" onclick="selectSmbFolder(\'' + escapedFolder + '\')">';
html += '<span class="folder-icon">&#128193;</span> ' + esc(folder);
if (isSelected) html += ' <span class="badge badge-success">ausgewählt</span>';
html += '</button>';
html += '<button type="button" class="folder-add-btn" onclick="event.stopPropagation(); toggleSmbCreateInput(\'' + escapedFolder + '\')" title="Unterordner erstellen">&#128193;+</button>';
html += '</div>';
html += '<div id="smb-create-row-' + CSS.escape(folder) + '" class="create-inline" style="display:none;"></div>';
});
const smbFolders = window.cachedSmbFolders || cachedSmbFolders;
if (smbFolders && smbFolders.length > 0) {
// Auto-expand path to current value, then render tree
expandTreePathsForValue('smb', currentValue);
const tree = buildFolderTree(smbFolders);
html += renderFolderTree(tree, 'smb', currentValue, 'selectSmbFolder', 'toggleSmbCreateInput', 0);
} else {
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
}
@@ -875,5 +1139,259 @@ function closeSmbFolderModal(event) {
document.getElementById('smbFolderModal').style.display = 'none';
cachedSmbFolders = null;
}
// --- FTP / SFTP ---
let cachedFtpFolders = null;
let ftpFolderTargetField = null;
function updateFtpDefaultPort() {
const proto = document.getElementById('ftp_protocol').value;
const portInput = document.getElementById('ftp_port');
const current = portInput.value;
if (proto === 'sftp' && (current === '21' || !current)) {
portInput.value = '22';
} else if (proto === 'ftp' && (current === '22' || !current)) {
portInput.value = '21';
}
}
async function testFtp() {
const btn = event.currentTarget;
setButtonLoading(btn, true);
try {
const resp = await fetch('/api/test-ftp', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
cachedFtpFolders = data.folders;
showAlert('FTP-Verbindung erfolgreich! Einstellungen gespeichert.', 'success');
showFtpFolderModal(null);
} else {
showAlert('FTP-Verbindung fehlgeschlagen: ' + data.error, 'error');
}
} catch (e) {
showAlert('Fehler: ' + e.message, 'error');
} finally {
setButtonLoading(btn, false);
}
}
async function processFtp() {
const btn = event.currentTarget;
setButtonLoading(btn, true);
try {
const resp = await fetch('/api/process-ftp', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.error) {
showAlert('FTP-Fehler: ' + data.error, 'error');
} else {
showAlert(`FTP-Abruf fertig: ${data.processed} verarbeitet, ${data.skipped || 0} uebersprungen, ${data.errors} Fehler`, data.errors > 0 ? 'warning' : 'success');
}
} catch (e) {
showAlert('Fehler: ' + e.message, 'error');
} finally {
setButtonLoading(btn, false);
}
}
function openFtpFolderPicker(targetField) {
ftpFolderTargetField = targetField;
// Always reload when opening to ensure fresh state
cachedFtpFolders = null;
window.cachedFtpFolders = null;
resetTreeState('ftp');
showFtpFolderModalLoading(targetField);
fetchFtpFolders(targetField);
}
function reloadFtpFolders() {
cachedFtpFolders = null;
resetTreeState('ftp');
showFtpFolderModalLoading(ftpFolderTargetField);
fetchFtpFolders(ftpFolderTargetField);
}
async function fetchFtpFolders(targetField) {
try {
const resp = await fetch('/api/test-ftp', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
cachedFtpFolders = data.folders;
window.cachedFtpFolders = data.folders;
if (targetField) {
const currentValue = document.getElementById(targetField).value;
await preloadTreePathForValue('ftp', currentValue);
}
showFtpFolderModal(targetField);
} else {
showFtpFolderModalError('FTP-Verbindung fehlgeschlagen: ' + data.error);
}
} catch (e) {
showFtpFolderModalError('Fehler: ' + e.message);
}
}
function showFtpFolderModalLoading(targetField) {
const modal = document.getElementById('ftpFolderModal');
document.getElementById('ftpFolderList').innerHTML = '';
document.getElementById('ftpFolderLoading').style.display = '';
document.getElementById('ftpFolderError').style.display = 'none';
modal.style.display = 'flex';
}
function showFtpFolderModal(targetField) {
if (targetField) ftpFolderTargetField = targetField;
const modal = document.getElementById('ftpFolderModal');
document.getElementById('ftpFolderLoading').style.display = 'none';
document.getElementById('ftpFolderError').style.display = 'none';
const list = document.getElementById('ftpFolderList');
const currentValue = ftpFolderTargetField ? document.getElementById(ftpFolderTargetField).value : '';
let html = '<div class="folder-picker-fields">';
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_source_path' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_source_path\')">Eingang Quelle: <strong>' + esc(document.getElementById('ftp_source_path').value || '(Wurzel)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_processed_path' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_processed_path\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('ftp_processed_path').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_source_path_ausgang' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_source_path_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('ftp_source_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_processed_path_ausgang' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_processed_path_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('ftp_processed_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
html += '</div>';
html += '<div class="folder-items">';
// Root option for source path
if (ftpFolderTargetField === 'ftp_source_path') {
const isRoot = currentValue === '';
html += '<div class="folder-row">';
html += '<button type="button" class="folder-item' + (isRoot ? ' selected' : '') + '" onclick="selectFtpFolder(\'\')">';
html += '<span class="folder-icon">&#128193;</span> (Wurzel des Servers)';
if (isRoot) html += ' <span class="badge badge-success">ausgewählt</span>';
html += '</button>';
html += '</div>';
}
const ftpFolders = window.cachedFtpFolders || cachedFtpFolders;
if (ftpFolders && ftpFolders.length > 0) {
// Auto-expand path to current value, then render tree
expandTreePathsForValue('ftp', currentValue);
const tree = buildFolderTree(ftpFolders);
html += renderFolderTree(tree, 'ftp', currentValue, 'selectFtpFolder', 'toggleFtpCreateInput', 0);
} else {
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
}
// Create new root folder option
html += '<div class="folder-row" style="border-top:1px solid var(--border);margin-top:0.5rem;padding-top:0.5rem;">';
html += '<button type="button" class="folder-item" onclick="event.stopPropagation(); toggleFtpCreateInput(\'\')" style="color:var(--primary);">';
html += '<span class="folder-icon">&#128193;+</span> Neuen Ordner erstellen';
html += '</button>';
html += '</div>';
html += '<div id="ftp-create-row-root" class="create-inline" style="display:none;"></div>';
html += '</div>';
list.innerHTML = html;
modal.style.display = 'flex';
}
function switchFtpFolderTarget(field) {
ftpFolderTargetField = field;
showFtpFolderModal(field);
}
function selectFtpFolder(folder) {
if (ftpFolderTargetField) {
document.getElementById(ftpFolderTargetField).value = folder;
}
showFtpFolderModal(ftpFolderTargetField);
}
function showFtpFolderModalError(msg) {
document.getElementById('ftpFolderLoading').style.display = 'none';
document.getElementById('ftpFolderError').textContent = msg;
document.getElementById('ftpFolderError').style.display = '';
}
function toggleFtpCreateInput(parentFolder) {
document.querySelectorAll('.create-inline[id^="ftp-create-row"]').forEach(el => {
const rowId = parentFolder === '' ? 'ftp-create-row-root' : 'ftp-create-row-' + CSS.escape(parentFolder);
if (el.id !== rowId) {
el.style.display = 'none';
el.innerHTML = '';
}
});
const rowId = parentFolder === '' ? 'ftp-create-row-root' : 'ftp-create-row-' + CSS.escape(parentFolder);
const row = document.getElementById(rowId);
if (!row) return;
if (row.style.display !== 'none') {
row.style.display = 'none';
row.innerHTML = '';
return;
}
const prefix = parentFolder ? parentFolder + '/' : '';
row.innerHTML =
'<div class="create-folder-inline">' +
'<span class="create-folder-prefix">' + esc(prefix) + '</span>' +
'<input type="text" class="create-folder-input" id="newFtpSubfolderInput" placeholder="Name" autofocus>' +
'<button type="button" class="btn btn-primary btn-sm" onclick="doCreateFtpFolder(\'' + parentFolder.replace(/'/g, "\\'") + '\')">OK</button>' +
'<button type="button" class="btn btn-secondary btn-sm" onclick="toggleFtpCreateInput(\'' + parentFolder.replace(/'/g, "\\'") + '\')">Abbrechen</button>' +
'</div>' +
'<div id="ftpCreateError" class="text-error" style="display:none;"></div>';
row.style.display = '';
const input = document.getElementById('newFtpSubfolderInput');
if (input) {
input.focus();
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doCreateFtpFolder(parentFolder); }
if (e.key === 'Escape') { toggleFtpCreateInput(parentFolder); }
});
}
}
async function doCreateFtpFolder(parentFolder) {
const input = document.getElementById('newFtpSubfolderInput');
const errorEl = document.getElementById('ftpCreateError');
if (!input) return;
const subName = input.value.trim();
if (!subName) {
errorEl.textContent = 'Bitte einen Namen eingeben.';
errorEl.style.display = '';
return;
}
const fullName = parentFolder ? parentFolder + '/' + subName : subName;
errorEl.style.display = 'none';
input.disabled = true;
try {
const resp = await fetch('/api/create-ftp-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_name: fullName })
});
const data = await resp.json();
if (data.success) {
cachedFtpFolders = data.folders;
if (ftpFolderTargetField) {
document.getElementById(ftpFolderTargetField).value = fullName;
}
showFtpFolderModal(ftpFolderTargetField);
} else {
errorEl.textContent = data.error;
errorEl.style.display = '';
input.disabled = false;
}
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
input.disabled = false;
}
}
function closeFtpFolderModal(event) {
if (event && event.target !== document.getElementById('ftpFolderModal')) return;
document.getElementById('ftpFolderModal').style.display = 'none';
cachedFtpFolders = null;
}
</script>
{% endblock %}