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
+53 -1
View File
@@ -15,7 +15,8 @@ from app.database import init_db, get_settings, save_settings, get_log_entries,
from app.mail_processor import process_mailbox, send_test_email, test_imap_connection, create_imap_folder
from app.scheduler import start_scheduler, configure_job, get_scheduler_status
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders, list_smb_subfolders
from app.ftp_processor import process_ftp, test_ftp_connection, create_ftp_folder, list_ftp_folders, list_ftp_subfolders
from app.amazon_processor import (
start_login as amazon_start_login,
submit_otp as amazon_submit_otp,
@@ -122,6 +123,18 @@ async def _save_form_settings(request: Request) -> dict:
"smb_source_path_ausgang": form.get("smb_source_path_ausgang", ""),
"smb_processed_path_ausgang": form.get("smb_processed_path_ausgang", ""),
"smb_mode": form.get("smb_mode", "forward"),
# FTP / SFTP
"ftp_enabled": form.get("ftp_enabled", "false"),
"ftp_protocol": form.get("ftp_protocol", "sftp"),
"ftp_server": form.get("ftp_server", ""),
"ftp_port": form.get("ftp_port", "22"),
"ftp_username": form.get("ftp_username", ""),
"ftp_password": form.get("ftp_password") or current.get("ftp_password", ""),
"ftp_source_path": form.get("ftp_source_path", ""),
"ftp_processed_path": form.get("ftp_processed_path", "Verarbeitet"),
"ftp_source_path_ausgang": form.get("ftp_source_path_ausgang", ""),
"ftp_processed_path_ausgang": form.get("ftp_processed_path_ausgang", ""),
"ftp_mode": form.get("ftp_mode", "forward"),
# Debug
"debug_save_amazon_pdfs": form.get("debug_save_amazon_pdfs", "false"),
}
@@ -219,6 +232,45 @@ async def api_create_smb_folder(request: Request):
return JSONResponse(result)
@app.get("/api/list-smb-subfolders")
async def api_list_smb_subfolders(request: Request):
parent = request.query_params.get("path", "")
result = await list_smb_subfolders(parent)
return JSONResponse(result)
@app.post("/api/test-ftp")
async def api_test_ftp(request: Request):
await _save_form_settings(request)
result = await test_ftp_connection()
return JSONResponse(result)
@app.post("/api/process-ftp")
async def api_process_ftp(request: Request):
await _save_form_settings(request)
result = await process_ftp()
return JSONResponse(result)
@app.post("/api/create-ftp-folder")
async def api_create_ftp_folder(request: Request):
body = await request.json()
folder_name = body.get("folder_name", "")
result = await create_ftp_folder(folder_name)
if result["success"]:
folders_result = await list_ftp_folders()
result["folders"] = folders_result.get("folders", [])
return JSONResponse(result)
@app.get("/api/list-ftp-subfolders")
async def api_list_ftp_subfolders(request: Request):
parent = request.query_params.get("path", "")
result = await list_ftp_subfolders(parent)
return JSONResponse(result)
@app.get("/log", response_class=HTMLResponse)
async def log_page(request: Request):
logs = await get_log_entries(limit=500)