first commit
This commit is contained in:
commit
cb34aa00af
|
|
@ -0,0 +1,16 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libzbar0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
RUN mkdir -p /data/uploads
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import os
|
||||
import aiosqlite
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
|
||||
|
||||
_fernet = None
|
||||
|
||||
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"}
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"imap_server": "",
|
||||
"imap_port": "993",
|
||||
"imap_ssl": "true",
|
||||
"imap_username": "",
|
||||
"imap_password": "",
|
||||
"smtp_server": "",
|
||||
"smtp_port": "587",
|
||||
"smtp_ssl": "starttls",
|
||||
"smtp_username": "",
|
||||
"smtp_password": "",
|
||||
"lexoffice_email": "",
|
||||
"source_folder": "Rechnungen",
|
||||
"processed_folder": "Rechnungen/Verarbeitet",
|
||||
"interval_minutes": "5",
|
||||
"scheduler_enabled": "false",
|
||||
"fetch_since_date": "",
|
||||
# SMB
|
||||
"smb_enabled": "false",
|
||||
"smb_server": "",
|
||||
"smb_port": "445",
|
||||
"smb_username": "",
|
||||
"smb_password": "",
|
||||
"smb_domain": "",
|
||||
"smb_share": "",
|
||||
"smb_source_path": "",
|
||||
"smb_processed_path": "Verarbeitet",
|
||||
"smb_mode": "forward",
|
||||
}
|
||||
|
||||
|
||||
async def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is not None:
|
||||
return _fernet
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT value FROM settings WHERE key = 'encryption_key'"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
key = row[0].encode()
|
||||
else:
|
||||
key = Fernet.generate_key()
|
||||
await db.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('encryption_key', ?)",
|
||||
(key.decode(),),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
_fernet = Fernet(key)
|
||||
return _fernet
|
||||
|
||||
|
||||
def _encrypt(fernet: Fernet, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return fernet.encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def _decrypt(fernet: Fernet, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return fernet.decrypt(value.encode()).decode()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
email_subject TEXT,
|
||||
email_from TEXT,
|
||||
attachments_count INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT
|
||||
)
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
# Insert default settings if not present
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Ensure encryption key exists
|
||||
await _get_fernet()
|
||||
|
||||
|
||||
async def get_settings() -> dict:
|
||||
fernet = await _get_fernet()
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT key, value FROM settings WHERE key != 'encryption_key'"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
settings = {}
|
||||
for key, value in rows:
|
||||
if key in ENCRYPTED_KEYS:
|
||||
settings[key] = _decrypt(fernet, value)
|
||||
else:
|
||||
settings[key] = value
|
||||
return settings
|
||||
|
||||
|
||||
async def save_settings(data: dict):
|
||||
fernet = await _get_fernet()
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
for key, value in data.items():
|
||||
if key == "encryption_key":
|
||||
continue
|
||||
store_value = _encrypt(fernet, value) if key in ENCRYPTED_KEYS else value
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, store_value),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def add_log_entry(
|
||||
email_subject: str,
|
||||
email_from: str,
|
||||
attachments_count: int,
|
||||
status: str,
|
||||
error_message: str = "",
|
||||
):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO processing_log
|
||||
(email_subject, email_from, attachments_count, status, error_message)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(email_subject, email_from, attachments_count, status, error_message),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_log_entries(limit: int = 100) -> list[dict]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM processing_log ORDER BY id DESC LIMIT ?", (limit,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
import imaplib
|
||||
import smtplib
|
||||
import ssl
|
||||
import email
|
||||
from email import policy
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.text import MIMEText
|
||||
from email import encoders
|
||||
import logging
|
||||
|
||||
from app.database import get_settings, add_log_entry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
||||
server = settings["imap_server"]
|
||||
port = int(settings.get("imap_port", 993))
|
||||
use_ssl = settings.get("imap_ssl", "true") == "true"
|
||||
|
||||
if use_ssl:
|
||||
ctx = ssl.create_default_context()
|
||||
conn = imaplib.IMAP4_SSL(server, port, ssl_context=ctx)
|
||||
else:
|
||||
conn = imaplib.IMAP4(server, port)
|
||||
|
||||
conn.login(settings["imap_username"], settings["imap_password"])
|
||||
return conn
|
||||
|
||||
|
||||
def _connect_smtp(settings: dict) -> smtplib.SMTP | smtplib.SMTP_SSL:
|
||||
server = settings["smtp_server"]
|
||||
port = int(settings.get("smtp_port", 587))
|
||||
mode = settings.get("smtp_ssl", "starttls")
|
||||
|
||||
if mode == "ssl":
|
||||
ctx = ssl.create_default_context()
|
||||
conn = smtplib.SMTP_SSL(server, port, context=ctx)
|
||||
else:
|
||||
conn = smtplib.SMTP(server, port)
|
||||
if mode == "starttls":
|
||||
ctx = ssl.create_default_context()
|
||||
conn.starttls(context=ctx)
|
||||
|
||||
conn.login(settings["smtp_username"], settings["smtp_password"])
|
||||
return conn
|
||||
|
||||
|
||||
def _extract_attachments(msg: email.message.Message) -> list[tuple[str, bytes]]:
|
||||
attachments = []
|
||||
for part in msg.walk():
|
||||
content_disposition = part.get("Content-Disposition", "")
|
||||
if "attachment" not in content_disposition and "inline" not in content_disposition:
|
||||
continue
|
||||
filename = part.get_filename()
|
||||
if not filename:
|
||||
continue
|
||||
# Decode filename if encoded
|
||||
decoded_parts = email.header.decode_header(filename)
|
||||
filename = ""
|
||||
for data, charset in decoded_parts:
|
||||
if isinstance(data, bytes):
|
||||
filename += data.decode(charset or "utf-8", errors="replace")
|
||||
else:
|
||||
filename += data
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
attachments.append((filename, payload))
|
||||
return attachments
|
||||
|
||||
|
||||
def _build_forward_email(
|
||||
from_addr: str,
|
||||
to_addr: str,
|
||||
original_subject: str,
|
||||
original_from: str,
|
||||
attachments: list[tuple[str, bytes]],
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = f"Belegimport: {original_subject}"
|
||||
|
||||
body = (
|
||||
f"Automatisch weitergeleitet von LexOffice Belegimport.\n"
|
||||
f"Original-Absender: {original_from}\n"
|
||||
f"Original-Betreff: {original_subject}\n"
|
||||
f"Anzahl Anhänge: {len(attachments)}"
|
||||
)
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
for filename, data in attachments:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(data)
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", "attachment", filename=filename)
|
||||
msg.attach(part)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _ensure_folder_exists(conn: imaplib.IMAP4, folder: str):
|
||||
status, _ = conn.select(f'"{folder}"')
|
||||
if status != "OK":
|
||||
conn.create(f'"{folder}"')
|
||||
conn.subscribe(f'"{folder}"')
|
||||
# Go back to INBOX to not stay in the folder
|
||||
conn.select("INBOX")
|
||||
|
||||
|
||||
def _move_email(conn: imaplib.IMAP4, msg_uid: bytes, dest_folder: str):
|
||||
result = conn.uid("COPY", msg_uid, f'"{dest_folder}"')
|
||||
if result[0] == "OK":
|
||||
conn.uid("STORE", msg_uid, "+FLAGS", "(\\Deleted)")
|
||||
conn.expunge()
|
||||
|
||||
|
||||
async def process_mailbox() -> dict:
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("imap_server") or not settings.get("lexoffice_email"):
|
||||
logger.warning("IMAP oder LexOffice-Email nicht konfiguriert")
|
||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
|
||||
|
||||
source_folder = settings.get("source_folder", "INBOX")
|
||||
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
|
||||
lexoffice_email = settings["lexoffice_email"]
|
||||
smtp_from = settings.get("smtp_username", "")
|
||||
|
||||
processed = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
imap_conn = None
|
||||
smtp_conn = None
|
||||
|
||||
try:
|
||||
imap_conn = _connect_imap(settings)
|
||||
smtp_conn = _connect_smtp(settings)
|
||||
|
||||
_ensure_folder_exists(imap_conn, processed_folder)
|
||||
|
||||
status, _ = imap_conn.select(f'"{source_folder}"')
|
||||
if status != "OK":
|
||||
raise Exception(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
|
||||
|
||||
# Build IMAP search criteria
|
||||
search_criteria = "ALL"
|
||||
fetch_since = settings.get("fetch_since_date", "")
|
||||
if fetch_since:
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
|
||||
imap_date = dt.strftime("%d-%b-%Y")
|
||||
search_criteria = f'(SINCE {imap_date})'
|
||||
except ValueError:
|
||||
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
|
||||
|
||||
status, data = imap_conn.uid("SEARCH", None, search_criteria)
|
||||
if status != "OK" or not data[0]:
|
||||
logger.info("Keine Emails im Ordner gefunden")
|
||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
msg_uids = data[0].split()
|
||||
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' gefunden")
|
||||
|
||||
for msg_uid in msg_uids:
|
||||
try:
|
||||
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
|
||||
raw_email = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw_email, policy=policy.default)
|
||||
|
||||
subject = str(msg.get("Subject", "(Kein Betreff)"))
|
||||
from_addr = str(msg.get("From", "(Unbekannt)"))
|
||||
|
||||
attachments = _extract_attachments(msg)
|
||||
|
||||
if not attachments:
|
||||
skipped += 1
|
||||
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
|
||||
continue
|
||||
|
||||
forward_msg = _build_forward_email(
|
||||
from_addr=smtp_from,
|
||||
to_addr=lexoffice_email,
|
||||
original_subject=subject,
|
||||
original_from=from_addr,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
smtp_conn.send_message(forward_msg)
|
||||
|
||||
# Re-select source folder before move (in case _ensure_folder changed it)
|
||||
imap_conn.select(f'"{source_folder}"')
|
||||
_move_email(imap_conn, msg_uid, processed_folder)
|
||||
|
||||
# Re-select after expunge to keep UIDs valid
|
||||
imap_conn.select(f'"{source_folder}"')
|
||||
|
||||
processed += 1
|
||||
logger.info(
|
||||
f"Verarbeitet: {subject} ({len(attachments)} Anhänge)"
|
||||
)
|
||||
await add_log_entry(
|
||||
email_subject=subject,
|
||||
email_from=from_addr,
|
||||
attachments_count=len(attachments),
|
||||
status="success",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
|
||||
try:
|
||||
await add_log_entry(
|
||||
email_subject=subject if "subject" in dir() else "?",
|
||||
email_from=from_addr if "from_addr" in dir() else "?",
|
||||
attachments_count=0,
|
||||
status="error",
|
||||
error_message=str(e),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Verbindungsfehler: {e}")
|
||||
await add_log_entry(
|
||||
email_subject="",
|
||||
email_from="",
|
||||
attachments_count=0,
|
||||
status="error",
|
||||
error_message=f"Verbindungsfehler: {e}",
|
||||
)
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
||||
|
||||
finally:
|
||||
if imap_conn:
|
||||
try:
|
||||
imap_conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
if smtp_conn:
|
||||
try:
|
||||
smtp_conn.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
|
||||
)
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
async def send_test_email() -> dict:
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("smtp_server") or not settings.get("lexoffice_email"):
|
||||
return {"success": False, "error": "SMTP oder LexOffice-Email nicht konfiguriert"}
|
||||
|
||||
try:
|
||||
smtp_conn = _connect_smtp(settings)
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = settings["smtp_username"]
|
||||
msg["To"] = settings["lexoffice_email"]
|
||||
msg["Subject"] = "LexOffice Belegimport - Test-Email"
|
||||
msg.attach(MIMEText(
|
||||
"Dies ist eine Test-Email vom LexOffice Belegimport Service.\n"
|
||||
"Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.",
|
||||
"plain",
|
||||
"utf-8",
|
||||
))
|
||||
|
||||
smtp_conn.send_message(msg)
|
||||
smtp_conn.quit()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test-Email fehlgeschlagen: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def create_imap_folder(folder_name: str) -> dict:
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("imap_server"):
|
||||
return {"success": False, "error": "IMAP nicht konfiguriert"}
|
||||
|
||||
if not folder_name or not folder_name.strip():
|
||||
return {"success": False, "error": "Ordnername darf nicht leer sein"}
|
||||
|
||||
folder_name = folder_name.strip()
|
||||
|
||||
try:
|
||||
conn = _connect_imap(settings)
|
||||
status, response = conn.create(f'"{folder_name}"')
|
||||
if status == "OK":
|
||||
conn.subscribe(f'"{folder_name}"')
|
||||
conn.logout()
|
||||
|
||||
if status == "OK":
|
||||
return {"success": True}
|
||||
else:
|
||||
msg = response[0].decode() if response and isinstance(response[0], bytes) else str(response)
|
||||
return {"success": False, "error": msg}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ordner erstellen fehlgeschlagen: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def test_imap_connection() -> dict:
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("imap_server"):
|
||||
return {"success": False, "error": "IMAP nicht konfiguriert", "folders": []}
|
||||
|
||||
try:
|
||||
conn = _connect_imap(settings)
|
||||
status, folder_data = conn.list()
|
||||
folders = []
|
||||
delimiter = "."
|
||||
if status == "OK":
|
||||
for item in folder_data:
|
||||
decoded = item.decode() if isinstance(item, bytes) else item
|
||||
# Parse IMAP LIST response: (\\flags) "delimiter" "name"
|
||||
parts = decoded.split('"')
|
||||
if len(parts) >= 4:
|
||||
# parts[1] is the delimiter, parts[3] is the folder name
|
||||
if not delimiter or delimiter == ".":
|
||||
delimiter = parts[1]
|
||||
folders.append(parts[-2])
|
||||
elif len(parts) >= 2:
|
||||
folders.append(parts[-1].strip())
|
||||
conn.logout()
|
||||
return {"success": True, "folders": sorted(folders), "delimiter": delimiter}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP-Test fehlgeschlagen: {e}")
|
||||
return {"success": False, "error": str(e), "folders": [], "delimiter": "."}
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Form, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
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
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
start_scheduler()
|
||||
settings = await get_settings()
|
||||
interval = int(settings.get("interval_minutes", 5))
|
||||
enabled = settings.get("scheduler_enabled", "false") == "true"
|
||||
configure_job(interval, enabled)
|
||||
logger.info("LexOffice Belegimport gestartet")
|
||||
yield
|
||||
logger.info("LexOffice Belegimport beendet")
|
||||
|
||||
|
||||
app = FastAPI(title="LexOffice Belegimport", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
settings = await get_settings()
|
||||
logs = await get_log_entries(limit=20)
|
||||
status = get_scheduler_status()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"settings": settings,
|
||||
"logs": logs,
|
||||
"status": status,
|
||||
"message": None,
|
||||
"message_type": None,
|
||||
})
|
||||
|
||||
|
||||
async def _save_form_settings(request: Request) -> dict:
|
||||
"""Extract form data and save settings. Returns the saved data dict."""
|
||||
form = await request.form()
|
||||
current = await get_settings()
|
||||
|
||||
data = {
|
||||
"imap_server": form.get("imap_server", ""),
|
||||
"imap_port": form.get("imap_port", "993"),
|
||||
"imap_ssl": form.get("imap_ssl", "true"),
|
||||
"imap_username": form.get("imap_username", ""),
|
||||
"imap_password": form.get("imap_password") or current.get("imap_password", ""),
|
||||
"smtp_server": form.get("smtp_server", ""),
|
||||
"smtp_port": form.get("smtp_port", "587"),
|
||||
"smtp_ssl": form.get("smtp_ssl", "starttls"),
|
||||
"smtp_username": form.get("smtp_username", ""),
|
||||
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
||||
"lexoffice_email": form.get("lexoffice_email", ""),
|
||||
"source_folder": form.get("source_folder", "Rechnungen"),
|
||||
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
|
||||
"interval_minutes": form.get("interval_minutes", "5"),
|
||||
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
||||
"fetch_since_date": form.get("fetch_since_date", ""),
|
||||
# SMB
|
||||
"smb_enabled": form.get("smb_enabled", "false"),
|
||||
"smb_server": form.get("smb_server", ""),
|
||||
"smb_port": form.get("smb_port", "445"),
|
||||
"smb_username": form.get("smb_username", ""),
|
||||
"smb_password": form.get("smb_password") or current.get("smb_password", ""),
|
||||
"smb_domain": form.get("smb_domain", ""),
|
||||
"smb_share": form.get("smb_share", ""),
|
||||
"smb_source_path": form.get("smb_source_path", ""),
|
||||
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
|
||||
"smb_mode": form.get("smb_mode", "forward"),
|
||||
}
|
||||
|
||||
await save_settings(data)
|
||||
|
||||
interval = int(data["interval_minutes"])
|
||||
enabled = data["scheduler_enabled"] == "true"
|
||||
configure_job(interval, enabled)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/settings", response_class=HTMLResponse)
|
||||
async def save(request: Request):
|
||||
await _save_form_settings(request)
|
||||
|
||||
settings = await get_settings()
|
||||
logs = await get_log_entries(limit=20)
|
||||
status = get_scheduler_status()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"settings": settings,
|
||||
"logs": logs,
|
||||
"status": status,
|
||||
"message": "Einstellungen gespeichert",
|
||||
"message_type": "success",
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/save-settings")
|
||||
async def api_save_settings(request: Request):
|
||||
await _save_form_settings(request)
|
||||
return JSONResponse({"success": True})
|
||||
|
||||
|
||||
@app.post("/api/test-imap")
|
||||
async def api_test_imap(request: Request):
|
||||
# Save settings first, then test
|
||||
await _save_form_settings(request)
|
||||
result = await test_imap_connection()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/test-email")
|
||||
async def api_test_email(request: Request):
|
||||
# Save settings first, then test
|
||||
await _save_form_settings(request)
|
||||
result = await send_test_email()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
async def api_process(request: Request):
|
||||
# Save settings first, then process
|
||||
await _save_form_settings(request)
|
||||
result = await process_mailbox()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/create-folder")
|
||||
async def api_create_folder(request: Request):
|
||||
body = await request.json()
|
||||
folder_name = body.get("folder_name", "")
|
||||
result = await create_imap_folder(folder_name)
|
||||
if result["success"]:
|
||||
# Return updated folder list
|
||||
folders_result = await test_imap_connection()
|
||||
result["folders"] = folders_result.get("folders", [])
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/test-smb")
|
||||
async def api_test_smb(request: Request):
|
||||
await _save_form_settings(request)
|
||||
result = await test_smb_connection()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/process-smb")
|
||||
async def api_process_smb(request: Request):
|
||||
await _save_form_settings(request)
|
||||
result = await process_smb_share()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/create-smb-folder")
|
||||
async def api_create_smb_folder(request: Request):
|
||||
body = await request.json()
|
||||
folder_name = body.get("folder_name", "")
|
||||
result = await create_smb_folder(folder_name)
|
||||
if result["success"]:
|
||||
folders_result = await list_smb_folders()
|
||||
result["folders"] = folders_result.get("folders", [])
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/log", response_class=HTMLResponse)
|
||||
async def log_page(request: Request):
|
||||
logs = await get_log_entries(limit=500)
|
||||
return templates.TemplateResponse("log.html", {
|
||||
"request": request,
|
||||
"logs": logs,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status():
|
||||
return get_scheduler_status()
|
||||
|
||||
|
||||
# --- Scan Upload ---
|
||||
|
||||
# In-memory progress store for SSE
|
||||
_scan_progress: dict[str, list[dict]] = {}
|
||||
|
||||
|
||||
@app.get("/scan", response_class=HTMLResponse)
|
||||
async def scan_page(request: Request):
|
||||
return templates.TemplateResponse("scan.html", {"request": request})
|
||||
|
||||
|
||||
@app.post("/api/scan-upload-chunk")
|
||||
async def scan_upload_chunk(
|
||||
request: Request,
|
||||
file: UploadFile = Form(...),
|
||||
chunk_index: int = Form(...),
|
||||
total_chunks: int = Form(...),
|
||||
upload_id: str = Form(...),
|
||||
filename: str = Form(...),
|
||||
):
|
||||
# Validate upload_id is UUID-like to prevent path traversal
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400)
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
part_path = UPLOAD_DIR / f"{upload_id}.part"
|
||||
|
||||
# Append chunk to file
|
||||
mode = "ab" if chunk_index > 0 else "wb"
|
||||
content = await file.read()
|
||||
with open(part_path, mode) as f:
|
||||
f.write(content)
|
||||
|
||||
# If last chunk, rename to .pdf
|
||||
if chunk_index >= total_chunks - 1:
|
||||
pdf_path = UPLOAD_DIR / f"{upload_id}.pdf"
|
||||
part_path.rename(pdf_path)
|
||||
return JSONResponse({"status": "complete", "upload_id": upload_id})
|
||||
|
||||
return JSONResponse({"status": "ok", "chunk": chunk_index})
|
||||
|
||||
|
||||
@app.post("/api/scan-process")
|
||||
async def scan_process(request: Request):
|
||||
body = await request.json()
|
||||
upload_id = body.get("upload_id", "")
|
||||
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400)
|
||||
|
||||
pdf_path = UPLOAD_DIR / f"{upload_id}.pdf"
|
||||
if not pdf_path.exists():
|
||||
return JSONResponse({"error": "Upload nicht gefunden"}, status_code=404)
|
||||
|
||||
# Initialize progress
|
||||
_scan_progress[upload_id] = []
|
||||
|
||||
def progress_callback(stage, current, total, message=None):
|
||||
entry = {"stage": stage, "current": current, "total": total}
|
||||
if message:
|
||||
entry["message"] = message
|
||||
_scan_progress.setdefault(upload_id, []).append(entry)
|
||||
|
||||
# Process in background task
|
||||
async def _process():
|
||||
try:
|
||||
result = await process_scanned_pdf(str(pdf_path), progress_callback)
|
||||
_scan_progress.setdefault(upload_id, []).append({
|
||||
"stage": "done", "result": result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Scan-Verarbeitung fehlgeschlagen: {e}")
|
||||
_scan_progress.setdefault(upload_id, []).append({
|
||||
"stage": "error", "message": str(e)
|
||||
})
|
||||
finally:
|
||||
# Cleanup uploaded file
|
||||
try:
|
||||
pdf_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(_process())
|
||||
return JSONResponse({"status": "processing", "upload_id": upload_id})
|
||||
|
||||
|
||||
@app.get("/api/scan-status/{upload_id}")
|
||||
async def scan_status_sse(upload_id: str):
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400)
|
||||
|
||||
async def event_generator():
|
||||
seen = 0
|
||||
while True:
|
||||
entries = _scan_progress.get(upload_id, [])
|
||||
while seen < len(entries):
|
||||
entry = entries[seen]
|
||||
seen += 1
|
||||
|
||||
import json
|
||||
yield {"event": entry.get("stage", "status"), "data": json.dumps(entry)}
|
||||
|
||||
if entry.get("stage") in ("done", "error"):
|
||||
# Cleanup progress data
|
||||
_scan_progress.pop(upload_id, None)
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
@app.get("/api/separator-pdf")
|
||||
async def separator_pdf():
|
||||
pdf_bytes = generate_separator_pdf()
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": "attachment; filename=Trennseite_LexOffice.pdf"},
|
||||
)
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import io
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
from pyzbar.pyzbar import decode as decode_qr
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_H
|
||||
|
||||
from app.database import get_settings, add_log_entry
|
||||
from app.mail_processor import _connect_smtp, _build_forward_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEPARATOR_QR_CONTENT = "LEXOFFICE-TRENNUNG"
|
||||
UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "/data/uploads"))
|
||||
|
||||
|
||||
def detect_separator_pages(pdf_path: str, progress_callback=None) -> list[int]:
|
||||
"""Scan each page for QR codes. Returns list of page indices that are separators."""
|
||||
separator_pages = []
|
||||
doc = fitz.open(pdf_path)
|
||||
total = len(doc)
|
||||
|
||||
for page_num in range(total):
|
||||
if progress_callback:
|
||||
progress_callback("scan", page_num + 1, total)
|
||||
|
||||
page = doc[page_num]
|
||||
# Render page as image at 150 DPI (enough for QR detection)
|
||||
pix = page.get_pixmap(dpi=150)
|
||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||||
|
||||
# Scan for QR codes
|
||||
codes = decode_qr(img)
|
||||
for code in codes:
|
||||
try:
|
||||
data = code.data.decode("utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if data == SEPARATOR_QR_CONTENT:
|
||||
separator_pages.append(page_num)
|
||||
logger.debug(f"Trennseite erkannt auf Seite {page_num + 1}")
|
||||
break
|
||||
|
||||
doc.close()
|
||||
return separator_pages
|
||||
|
||||
|
||||
def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]:
|
||||
"""Split PDF at separator pages. Separator pages are excluded from output."""
|
||||
reader = PdfReader(pdf_path)
|
||||
total_pages = len(reader.pages)
|
||||
separator_set = set(separator_pages)
|
||||
|
||||
documents = []
|
||||
current_writer = None
|
||||
|
||||
for page_num in range(total_pages):
|
||||
if page_num in separator_set:
|
||||
# This is a separator page - finalize current document if any
|
||||
if current_writer and len(current_writer.pages) > 0:
|
||||
buf = io.BytesIO()
|
||||
current_writer.write(buf)
|
||||
documents.append(buf.getvalue())
|
||||
current_writer = None
|
||||
continue
|
||||
|
||||
# Regular page - add to current document
|
||||
if current_writer is None:
|
||||
current_writer = PdfWriter()
|
||||
current_writer.add_page(reader.pages[page_num])
|
||||
|
||||
# Don't forget the last document (after the last separator or if no separator at end)
|
||||
if current_writer and len(current_writer.pages) > 0:
|
||||
buf = io.BytesIO()
|
||||
current_writer.write(buf)
|
||||
documents.append(buf.getvalue())
|
||||
|
||||
return documents
|
||||
|
||||
|
||||
async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
||||
"""Full pipeline: detect separators, split, send each document to LexOffice."""
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("smtp_server") or not settings.get("lexoffice_email"):
|
||||
return {"error": "SMTP oder LexOffice-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1}
|
||||
|
||||
# Step 1: Detect separator pages (CPU-bound, run in thread)
|
||||
if progress_callback:
|
||||
progress_callback("status", 0, 0, "Analysiere PDF...")
|
||||
|
||||
separator_pages = await asyncio.to_thread(
|
||||
detect_separator_pages, pdf_path, progress_callback
|
||||
)
|
||||
|
||||
reader = PdfReader(pdf_path)
|
||||
total_pages = len(reader.pages)
|
||||
|
||||
if not separator_pages:
|
||||
# No separators found - treat entire PDF as one document
|
||||
if progress_callback:
|
||||
progress_callback("status", 0, 0, "Keine Trennseiten gefunden - sende gesamte PDF als ein Dokument")
|
||||
|
||||
# Step 2: Split PDF
|
||||
if progress_callback:
|
||||
progress_callback("status", 0, 0, f"{len(separator_pages)} Trennseite(n) erkannt, splitte PDF...")
|
||||
|
||||
documents = await asyncio.to_thread(split_pdf, pdf_path, separator_pages)
|
||||
|
||||
if not documents:
|
||||
return {"error": "Keine Dokumente nach dem Splitting gefunden", "total_pages": total_pages, "documents": 0, "sent": 0, "errors": 1}
|
||||
|
||||
# Step 3: Send each document to LexOffice
|
||||
if progress_callback:
|
||||
progress_callback("status", 0, 0, f"{len(documents)} Dokument(e) erkannt, starte Versand...")
|
||||
|
||||
sent = 0
|
||||
errors = 0
|
||||
smtp_conn = None
|
||||
|
||||
try:
|
||||
smtp_conn = _connect_smtp(settings)
|
||||
|
||||
for i, doc_bytes in enumerate(documents):
|
||||
try:
|
||||
if progress_callback:
|
||||
progress_callback("send", i + 1, len(documents))
|
||||
|
||||
filename = f"Scan_Dokument_{i + 1}.pdf"
|
||||
msg = _build_forward_email(
|
||||
from_addr=settings["smtp_username"],
|
||||
to_addr=settings["lexoffice_email"],
|
||||
original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
||||
original_from="Scan-Upload",
|
||||
attachments=[(filename, doc_bytes)],
|
||||
)
|
||||
smtp_conn.send_message(msg)
|
||||
sent += 1
|
||||
|
||||
await add_log_entry(
|
||||
email_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
||||
email_from="Scan-Upload",
|
||||
attachments_count=1,
|
||||
status="success",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
logger.error(f"Fehler beim Senden von Dokument {i + 1}: {e}")
|
||||
await add_log_entry(
|
||||
email_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
||||
email_from="Scan-Upload",
|
||||
attachments_count=1,
|
||||
status="error",
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMTP-Verbindungsfehler: {e}")
|
||||
return {
|
||||
"error": f"SMTP-Verbindungsfehler: {e}",
|
||||
"total_pages": total_pages,
|
||||
"documents": len(documents),
|
||||
"sent": sent,
|
||||
"errors": errors + 1,
|
||||
}
|
||||
finally:
|
||||
if smtp_conn:
|
||||
try:
|
||||
smtp_conn.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_pages": total_pages,
|
||||
"separator_pages": len(separator_pages),
|
||||
"documents": len(documents),
|
||||
"sent": sent,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _centered_textbox(page, y, text, fontsize, color):
|
||||
"""Insert centered text using textbox across full page width."""
|
||||
rect = fitz.Rect(40, y - fontsize, 555, y + fontsize)
|
||||
page.insert_textbox(
|
||||
rect, text,
|
||||
fontsize=fontsize,
|
||||
fontname="helv",
|
||||
color=color,
|
||||
align=fitz.TEXT_ALIGN_CENTER,
|
||||
)
|
||||
|
||||
|
||||
def generate_separator_pdf() -> bytes:
|
||||
"""Generate a printable A4 PDF with QR code for use as separator page."""
|
||||
# Generate QR code image
|
||||
qr = qrcode.QRCode(
|
||||
version=2,
|
||||
error_correction=ERROR_CORRECT_H,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(SEPARATOR_QR_CONTENT)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
||||
|
||||
# Create A4 PDF with PyMuPDF
|
||||
doc = fitz.open()
|
||||
page = doc.new_page(width=595, height=842) # A4 in points
|
||||
|
||||
# Title text
|
||||
_centered_textbox(page, 120, "TRENNSEITE", 36, (0, 0, 0))
|
||||
_centered_textbox(page, 170, "LexOffice Belegimport", 16, (0.4, 0.4, 0.4))
|
||||
|
||||
# Insert QR code image centered
|
||||
qr_bytes = io.BytesIO()
|
||||
qr_img.save(qr_bytes, format="PNG")
|
||||
qr_bytes.seek(0)
|
||||
|
||||
qr_size = 200
|
||||
x_center = (595 - qr_size) / 2
|
||||
y_center = 250
|
||||
rect = fitz.Rect(x_center, y_center, x_center + qr_size, y_center + qr_size)
|
||||
page.insert_image(rect, stream=qr_bytes.getvalue())
|
||||
|
||||
# Description text below QR
|
||||
_centered_textbox(page, y_center + qr_size + 40, "Dieses Blatt zwischen Dokumente legen.", 14, (0.3, 0.3, 0.3))
|
||||
_centered_textbox(page, y_center + qr_size + 70, "Es wird beim Scan-Upload automatisch erkannt und entfernt.", 12, (0.4, 0.4, 0.4))
|
||||
|
||||
# Dashed line border
|
||||
border_rect = fitz.Rect(40, 40, 555, 802)
|
||||
page.draw_rect(border_rect, color=(0.7, 0.7, 0.7), width=1)
|
||||
|
||||
# Bottom info
|
||||
_centered_textbox(page, 770, "--- Nicht entfernen - wird automatisch erkannt ---", 10, (0.5, 0.5, 0.5))
|
||||
|
||||
pdf_bytes = doc.tobytes()
|
||||
doc.close()
|
||||
return pdf_bytes
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from app.mail_processor import process_mailbox
|
||||
from app.smb_processor import process_smb_share
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JOB_ID = "mail_processor"
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
_is_processing = False
|
||||
|
||||
|
||||
async def _run_processor():
|
||||
global _is_processing
|
||||
if _is_processing:
|
||||
logger.info("Verarbeitung läuft bereits, überspringe diesen Durchlauf")
|
||||
return
|
||||
_is_processing = True
|
||||
try:
|
||||
logger.info("Starte automatische Email-Verarbeitung...")
|
||||
result = await process_mailbox()
|
||||
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
|
||||
|
||||
logger.info("Starte automatische SMB-Verarbeitung...")
|
||||
smb_result = await process_smb_share()
|
||||
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}")
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
|
||||
finally:
|
||||
_is_processing = False
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler gestartet")
|
||||
|
||||
|
||||
def configure_job(interval_minutes: int, enabled: bool):
|
||||
existing = scheduler.get_job(JOB_ID)
|
||||
if existing:
|
||||
scheduler.remove_job(JOB_ID)
|
||||
|
||||
if enabled and interval_minutes > 0:
|
||||
scheduler.add_job(
|
||||
_run_processor,
|
||||
trigger=IntervalTrigger(minutes=interval_minutes),
|
||||
id=JOB_ID,
|
||||
name="Email-Verarbeitung",
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(f"Scheduler konfiguriert: alle {interval_minutes} Minuten")
|
||||
else:
|
||||
logger.info("Scheduler deaktiviert")
|
||||
|
||||
|
||||
def get_scheduler_status() -> dict:
|
||||
job = scheduler.get_job(JOB_ID)
|
||||
if job and job.next_run_time:
|
||||
return {
|
||||
"enabled": True,
|
||||
"next_run": job.next_run_time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
"is_processing": _is_processing,
|
||||
}
|
||||
return {
|
||||
"enabled": False,
|
||||
"next_run": None,
|
||||
"is_processing": _is_processing,
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
import smbclient
|
||||
|
||||
from app.database import get_settings, add_log_entry
|
||||
from app.mail_processor import _connect_smtp, _build_forward_email
|
||||
from app.scanner import detect_separator_pages, split_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _smb_register_session(settings: dict) -> str:
|
||||
"""Register SMB session and return the UNC base path \\\\server\\share."""
|
||||
server = settings["smb_server"]
|
||||
port = int(settings.get("smb_port", 445))
|
||||
username = settings.get("smb_username", "")
|
||||
password = settings.get("smb_password", "")
|
||||
domain = settings.get("smb_domain", "")
|
||||
|
||||
if domain:
|
||||
auth_username = f"{domain}\\{username}"
|
||||
else:
|
||||
auth_username = username
|
||||
|
||||
smbclient.register_session(
|
||||
server,
|
||||
port=port,
|
||||
username=auth_username,
|
||||
password=password,
|
||||
connection_timeout=10,
|
||||
)
|
||||
|
||||
share = settings["smb_share"]
|
||||
return f"\\\\{server}\\{share}"
|
||||
|
||||
|
||||
def _smb_unc_path(base: str, *parts: str) -> str:
|
||||
"""Join UNC path segments using backslash."""
|
||||
result = base
|
||||
for p in parts:
|
||||
if p:
|
||||
result = result.rstrip("\\") + "\\" + p.replace("/", "\\").strip("\\")
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_smb_folder(path: str):
|
||||
"""Create SMB directory if it does not exist."""
|
||||
try:
|
||||
smbclient.stat(path)
|
||||
except OSError:
|
||||
smbclient.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def _list_pdf_files(source_path: str) -> list[str]:
|
||||
"""Return list of .pdf filenames in the SMB source directory."""
|
||||
try:
|
||||
entries = smbclient.listdir(source_path)
|
||||
except OSError:
|
||||
return []
|
||||
return sorted(
|
||||
e for e in entries if e.lower().endswith(".pdf") and not e.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
def _read_smb_file(filepath: str) -> bytes:
|
||||
"""Read a file from SMB share into memory."""
|
||||
with smbclient.open_file(filepath, mode="rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _move_smb_file(source: str, dest_dir: str, filename: str):
|
||||
"""Move a file on the SMB share to the destination directory."""
|
||||
dest = _smb_unc_path(dest_dir, filename)
|
||||
# Handle duplicate filenames
|
||||
try:
|
||||
smbclient.stat(dest)
|
||||
name, ext = os.path.splitext(filename)
|
||||
counter = 1
|
||||
while True:
|
||||
new_name = f"{name}_{counter}{ext}"
|
||||
dest = _smb_unc_path(dest_dir, new_name)
|
||||
try:
|
||||
smbclient.stat(dest)
|
||||
counter += 1
|
||||
except OSError:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
smbclient.rename(source, dest)
|
||||
|
||||
|
||||
def _list_smb_folders_recursive(
|
||||
base_path: str, max_depth: int = 3, _current_depth: int = 0, _prefix: str = ""
|
||||
) -> list[str]:
|
||||
"""Recursively list folders on the SMB share, returning relative paths."""
|
||||
folders = []
|
||||
try:
|
||||
for entry in smbclient.scandir(base_path):
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
rel_path = f"{_prefix}/{entry.name}" if _prefix else entry.name
|
||||
folders.append(rel_path)
|
||||
if _current_depth < max_depth - 1:
|
||||
sub_path = _smb_unc_path(base_path, entry.name)
|
||||
folders.extend(
|
||||
_list_smb_folders_recursive(
|
||||
sub_path, max_depth, _current_depth + 1, rel_path
|
||||
)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return folders
|
||||
|
||||
|
||||
async def process_smb_share() -> dict:
|
||||
"""Process PDF files from SMB share - main pipeline."""
|
||||
settings = await get_settings()
|
||||
|
||||
if settings.get("smb_enabled") != "true":
|
||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"}
|
||||
|
||||
if not settings.get("lexoffice_email"):
|
||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "LexOffice-Email nicht konfiguriert"}
|
||||
|
||||
mode = settings.get("smb_mode", "forward")
|
||||
smtp_from = settings.get("smtp_username", "")
|
||||
lexoffice_email = settings["lexoffice_email"]
|
||||
|
||||
processed = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
smtp_conn = None
|
||||
|
||||
try:
|
||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||
source_path = _smb_unc_path(base_path, settings.get("smb_source_path", ""))
|
||||
processed_path = _smb_unc_path(base_path, settings.get("smb_processed_path", "Verarbeitet"))
|
||||
|
||||
await asyncio.to_thread(_ensure_smb_folder, processed_path)
|
||||
|
||||
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
|
||||
if not pdf_files:
|
||||
logger.info("Keine PDF-Dateien im SMB-Ordner gefunden")
|
||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner gefunden")
|
||||
|
||||
smtp_conn = _connect_smtp(settings)
|
||||
|
||||
for filename in pdf_files:
|
||||
file_path = _smb_unc_path(source_path, filename)
|
||||
try:
|
||||
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
|
||||
|
||||
if mode == "separator":
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||
tmp.write(pdf_data)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
separator_pages = await asyncio.to_thread(
|
||||
detect_separator_pages, tmp_path, None
|
||||
)
|
||||
documents = await asyncio.to_thread(
|
||||
split_pdf, tmp_path, separator_pages
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
if not documents:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
for i, doc_bytes in enumerate(documents):
|
||||
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
|
||||
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
|
||||
msg = _build_forward_email(
|
||||
from_addr=smtp_from,
|
||||
to_addr=lexoffice_email,
|
||||
original_subject=subject,
|
||||
original_from="SMB-Import",
|
||||
attachments=[(doc_filename, doc_bytes)],
|
||||
)
|
||||
smtp_conn.send_message(msg)
|
||||
|
||||
await add_log_entry(
|
||||
email_subject=f"SMB: {filename}",
|
||||
email_from="SMB-Import",
|
||||
attachments_count=len(documents),
|
||||
status="success",
|
||||
)
|
||||
logger.info(
|
||||
f"SMB verarbeitet: {filename} -> {len(documents)} Dokument(e) "
|
||||
f"({len(separator_pages)} Trennseite(n))"
|
||||
)
|
||||
else:
|
||||
msg = _build_forward_email(
|
||||
from_addr=smtp_from,
|
||||
to_addr=lexoffice_email,
|
||||
original_subject=f"SMB-Import: {filename}",
|
||||
original_from="SMB-Import",
|
||||
attachments=[(filename, pdf_data)],
|
||||
)
|
||||
smtp_conn.send_message(msg)
|
||||
|
||||
await add_log_entry(
|
||||
email_subject=f"SMB: {filename}",
|
||||
email_from="SMB-Import",
|
||||
attachments_count=1,
|
||||
status="success",
|
||||
)
|
||||
logger.info(f"SMB verarbeitet: {filename}")
|
||||
|
||||
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
|
||||
processed += 1
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
|
||||
try:
|
||||
await add_log_entry(
|
||||
email_subject=f"SMB: {filename}",
|
||||
email_from="SMB-Import",
|
||||
attachments_count=0,
|
||||
status="error",
|
||||
error_message=str(e),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMB-Verbindungsfehler: {e}")
|
||||
try:
|
||||
await add_log_entry(
|
||||
email_subject="",
|
||||
email_from="SMB-Import",
|
||||
attachments_count=0,
|
||||
status="error",
|
||||
error_message=f"SMB-Verbindungsfehler: {e}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
||||
|
||||
finally:
|
||||
if smtp_conn:
|
||||
try:
|
||||
smtp_conn.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
|
||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
async def test_smb_connection() -> dict:
|
||||
"""Test SMB connection and return folder list."""
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||
return {"success": False, "error": "SMB-Server oder Freigabe nicht konfiguriert", "folders": []}
|
||||
|
||||
try:
|
||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||
folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 3)
|
||||
return {"success": True, "folders": sorted(folders)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMB-Test fehlgeschlagen: {e}")
|
||||
return {"success": False, "error": str(e), "folders": []}
|
||||
|
||||
|
||||
async def create_smb_folder(folder_path: str) -> dict:
|
||||
"""Create a folder on the SMB share."""
|
||||
settings = await get_settings()
|
||||
|
||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||
return {"success": False, "error": "SMB nicht konfiguriert"}
|
||||
|
||||
if not folder_path or not folder_path.strip():
|
||||
return {"success": False, "error": "Ordnername darf nicht leer sein"}
|
||||
|
||||
folder_path = folder_path.strip()
|
||||
|
||||
try:
|
||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||
full_path = _smb_unc_path(base_path, folder_path)
|
||||
await asyncio.to_thread(smbclient.makedirs, full_path, True)
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMB-Ordner erstellen fehlgeschlagen: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def list_smb_folders() -> dict:
|
||||
"""Return current folder list from SMB share."""
|
||||
settings = await get_settings()
|
||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||
return {"folders": []}
|
||||
try:
|
||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||
folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 3)
|
||||
return {"folders": sorted(folders)}
|
||||
except Exception:
|
||||
return {"folders": []}
|
||||
|
|
@ -0,0 +1,591 @@
|
|||
:root {
|
||||
--primary: #0066cc;
|
||||
--primary-hover: #0052a3;
|
||||
--success: #28a745;
|
||||
--success-hover: #218838;
|
||||
--warning: #ffc107;
|
||||
--error: #dc3545;
|
||||
--bg: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text: #333333;
|
||||
--text-muted: #6c757d;
|
||||
--border: #dee2e6;
|
||||
--nav-bg: #1a1a2e;
|
||||
--nav-text: #ffffff;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: var(--nav-bg);
|
||||
color: var(--nav-text);
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.nav-status {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.form-group-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-actions-main {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.55rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e9ecef;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #dde0e3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.row-error {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
small.text-muted {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Input with button (folder picker) */
|
||||
.input-with-btn {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.input-with-btn input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #e9ecef;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #dde0e3;
|
||||
}
|
||||
|
||||
/* Button loading state */
|
||||
.btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 550px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Folder picker fields (toggle between source/processed) */
|
||||
.folder-picker-fields {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-field-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.folder-field-btn:hover {
|
||||
border-color: var(--primary);
|
||||
background: #e8f0fe;
|
||||
}
|
||||
|
||||
.folder-field-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: #e8f0fe;
|
||||
}
|
||||
|
||||
.folder-field-btn strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Folder list items */
|
||||
.folder-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: #f0f4f8;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background: #e8f5e9;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Folder add button (+) */
|
||||
.folder-add-btn {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-add-btn:hover {
|
||||
background: #e8f0fe;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Inline create subfolder */
|
||||
.create-inline {
|
||||
padding: 0 0.5rem 0.4rem 2.2rem;
|
||||
}
|
||||
|
||||
.create-folder-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.create-folder-prefix {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.create-folder-input {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.create-folder-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Upload zone */
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone-active {
|
||||
border-color: var(--primary);
|
||||
background: #f0f6ff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Progress bars */
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: #e9ecef;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 5px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-bar-processing {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-status {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LexOffice Belegimport</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-brand">LexOffice Belegimport</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
|
||||
<a href="/scan" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
|
||||
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
|
||||
</div>
|
||||
<div class="nav-status">
|
||||
{% if status and status.enabled %}
|
||||
<span class="badge badge-success">Aktiv</span>
|
||||
{% if status.next_run %}
|
||||
<span class="text-muted">Nächster Lauf: {{ status.next_run }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-inactive">Inaktiv</span>
|
||||
{% endif %}
|
||||
{% if status and status.is_processing %}
|
||||
<span class="badge badge-warning">Verarbeitung läuft...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{% if message %}
|
||||
<div class="alert alert-{{ message_type or 'info' }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "log" %}
|
||||
{% set message = None %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Verarbeitungslog</h2>
|
||||
{% if logs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Betreff</th>
|
||||
<th>Absender</th>
|
||||
<th>Anhänge</th>
|
||||
<th>Status</th>
|
||||
<th>Fehlermeldung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
||||
<td>{{ log.id }}</td>
|
||||
<td>{{ log.timestamp }}</td>
|
||||
<td>{{ log.email_subject or '-' }}</td>
|
||||
<td>{{ log.email_from or '-' }}</td>
|
||||
<td>{{ log.attachments_count }}</td>
|
||||
<td>
|
||||
{% if log.status == 'success' %}
|
||||
<span class="badge badge-success">OK</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">Fehler</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.error_message or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">Noch keine Einträge vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "scan" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Scan-Upload</h2>
|
||||
<p class="text-muted" style="margin-bottom:1rem;">
|
||||
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente an LexOffice gesendet.
|
||||
</p>
|
||||
|
||||
<!-- Upload Zone -->
|
||||
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
||||
<div class="upload-icon">📄</div>
|
||||
<div class="upload-text">PDF hierher ziehen oder klicken zum Auswählen</div>
|
||||
<div class="upload-hint">Unterstützt große Dateien (1 GB+)</div>
|
||||
<input type="file" id="fileInput" accept=".pdf,application/pdf" style="display:none;">
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div id="uploadProgress" style="display:none;">
|
||||
<div class="progress-header">
|
||||
<span id="uploadFilename"></span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar-track">
|
||||
<div id="uploadBar" class="progress-bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="uploadStatus" class="text-muted" style="margin-top:0.3rem;font-size:0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Progress -->
|
||||
<div id="processProgress" style="display:none;">
|
||||
<div class="progress-header">
|
||||
<span>Verarbeitung</span>
|
||||
<span id="processStage"></span>
|
||||
</div>
|
||||
<div class="progress-bar-track">
|
||||
<div id="processBar" class="progress-bar-fill progress-bar-processing" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="processStatus" class="text-muted" style="margin-top:0.3rem;font-size:0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div id="resultArea" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Trennseiten</h2>
|
||||
<p class="text-muted" style="margin-bottom:1rem;">
|
||||
Trennseiten ausdrucken und zwischen die Dokumente legen, bevor der Stapel gescannt wird.
|
||||
</p>
|
||||
<a href="/api/separator-pdf" class="btn btn-secondary" download>Trennseiten-PDF herunterladen</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
let currentUploadId = null;
|
||||
|
||||
// Drag & Drop
|
||||
const zone = document.getElementById('uploadZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
zone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.add('upload-zone-active');
|
||||
});
|
||||
|
||||
zone.addEventListener('dragleave', () => {
|
||||
zone.classList.remove('upload-zone-active');
|
||||
});
|
||||
|
||||
zone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('upload-zone-active');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) handleFile(files[0]);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length > 0) handleFile(fileInput.files[0]);
|
||||
});
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID ? crypto.randomUUID() : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file.name.toLowerCase().endsWith('.pdf')) {
|
||||
showResult('Bitte eine PDF-Datei auswählen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset UI
|
||||
document.getElementById('resultArea').style.display = 'none';
|
||||
document.getElementById('processProgress').style.display = 'none';
|
||||
zone.style.display = 'none';
|
||||
|
||||
const uploadId = generateUUID();
|
||||
currentUploadId = uploadId;
|
||||
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const uploadProgress = document.getElementById('uploadProgress');
|
||||
const uploadBar = document.getElementById('uploadBar');
|
||||
const uploadPercent = document.getElementById('uploadPercent');
|
||||
const uploadStatus = document.getElementById('uploadStatus');
|
||||
const uploadFilename = document.getElementById('uploadFilename');
|
||||
|
||||
uploadFilename.textContent = file.name + ' (' + formatBytes(file.size) + ')';
|
||||
uploadBar.style.width = '0%';
|
||||
uploadPercent.textContent = '0%';
|
||||
uploadStatus.textContent = 'Upload wird gestartet...';
|
||||
uploadProgress.style.display = '';
|
||||
|
||||
// Chunked upload
|
||||
try {
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', chunk);
|
||||
formData.append('chunk_index', i);
|
||||
formData.append('total_chunks', totalChunks);
|
||||
formData.append('upload_id', uploadId);
|
||||
formData.append('filename', file.name);
|
||||
|
||||
const resp = await fetch('/api/scan-upload-chunk', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Upload fehlgeschlagen (HTTP ' + resp.status + ')');
|
||||
}
|
||||
|
||||
const pct = Math.round(((i + 1) / totalChunks) * 100);
|
||||
uploadBar.style.width = pct + '%';
|
||||
uploadPercent.textContent = pct + '%';
|
||||
uploadStatus.textContent = 'Chunk ' + (i + 1) + '/' + totalChunks + ' (' + formatBytes(end) + ' von ' + formatBytes(file.size) + ')';
|
||||
}
|
||||
|
||||
uploadStatus.textContent = 'Upload abgeschlossen. Starte Verarbeitung...';
|
||||
startProcessing(uploadId);
|
||||
|
||||
} catch (e) {
|
||||
showResult('Upload-Fehler: ' + e.message, 'error');
|
||||
resetUploadZone();
|
||||
}
|
||||
}
|
||||
|
||||
async function startProcessing(uploadId) {
|
||||
const processProgress = document.getElementById('processProgress');
|
||||
const processBar = document.getElementById('processBar');
|
||||
const processStage = document.getElementById('processStage');
|
||||
const processStatus = document.getElementById('processStatus');
|
||||
|
||||
processBar.style.width = '0%';
|
||||
processStage.textContent = '';
|
||||
processStatus.textContent = 'Starte Verarbeitung...';
|
||||
processProgress.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/scan-process', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upload_id: uploadId }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Verarbeitung konnte nicht gestartet werden');
|
||||
}
|
||||
|
||||
// Subscribe to SSE for progress
|
||||
listenProgress(uploadId);
|
||||
|
||||
} catch (e) {
|
||||
showResult('Fehler: ' + e.message, 'error');
|
||||
resetUploadZone();
|
||||
}
|
||||
}
|
||||
|
||||
function listenProgress(uploadId) {
|
||||
const processBar = document.getElementById('processBar');
|
||||
const processStage = document.getElementById('processStage');
|
||||
const processStatus = document.getElementById('processStatus');
|
||||
|
||||
const evtSource = new EventSource('/api/scan-status/' + uploadId);
|
||||
|
||||
evtSource.addEventListener('scan', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const pct = Math.round((data.current / data.total) * 100);
|
||||
processBar.style.width = pct + '%';
|
||||
processStage.textContent = pct + '%';
|
||||
processStatus.textContent = 'Seite ' + data.current + ' von ' + data.total + ' analysiert...';
|
||||
});
|
||||
|
||||
evtSource.addEventListener('status', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
processStatus.textContent = data.message || '';
|
||||
});
|
||||
|
||||
evtSource.addEventListener('send', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const pct = Math.round((data.current / data.total) * 100);
|
||||
processBar.style.width = pct + '%';
|
||||
processStage.textContent = data.current + '/' + data.total;
|
||||
processStatus.textContent = 'Dokument ' + data.current + ' von ' + data.total + ' wird gesendet...';
|
||||
});
|
||||
|
||||
evtSource.addEventListener('done', (e) => {
|
||||
evtSource.close();
|
||||
const data = JSON.parse(e.data);
|
||||
const result = data.result || {};
|
||||
|
||||
let msg = '';
|
||||
if (result.error) {
|
||||
msg = result.error;
|
||||
showResult(msg, 'error');
|
||||
} else {
|
||||
msg = result.documents + ' Dokument(e) erkannt';
|
||||
if (result.separator_pages > 0) {
|
||||
msg += ' (' + result.separator_pages + ' Trennseite(n))';
|
||||
}
|
||||
msg += ', ' + result.sent + ' an LexOffice gesendet';
|
||||
if (result.errors > 0) {
|
||||
msg += ', ' + result.errors + ' Fehler';
|
||||
showResult(msg, 'warning');
|
||||
} else {
|
||||
showResult(msg, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('processProgress').style.display = 'none';
|
||||
resetUploadZone();
|
||||
});
|
||||
|
||||
evtSource.addEventListener('error', (e) => {
|
||||
// Check if it's an SSE error event from our backend
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
showResult('Verarbeitungsfehler: ' + (data.message || 'Unbekannter Fehler'), 'error');
|
||||
} catch {
|
||||
// Connection error
|
||||
showResult('Verbindung zum Server verloren.', 'error');
|
||||
}
|
||||
evtSource.close();
|
||||
document.getElementById('processProgress').style.display = 'none';
|
||||
resetUploadZone();
|
||||
});
|
||||
}
|
||||
|
||||
function showResult(message, type) {
|
||||
const area = document.getElementById('resultArea');
|
||||
area.innerHTML = '<div class="alert alert-' + type + '">' + escapeHtml(message) + '</div>';
|
||||
area.style.display = '';
|
||||
document.getElementById('uploadProgress').style.display = 'none';
|
||||
}
|
||||
|
||||
function resetUploadZone() {
|
||||
zone.style.display = '';
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,805 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "settings" %}
|
||||
|
||||
{% block content %}
|
||||
<form id="settingsForm" method="post" action="/settings">
|
||||
<div class="card">
|
||||
<h2>IMAP Einstellungen (Empfang)</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="imap_server">Server</label>
|
||||
<input type="text" id="imap_server" name="imap_server"
|
||||
value="{{ settings.get('imap_server', '') }}" placeholder="imap.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="imap_port">Port</label>
|
||||
<input type="number" id="imap_port" name="imap_port"
|
||||
value="{{ settings.get('imap_port', '993') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="imap_ssl">Verschlüsselung</label>
|
||||
<select id="imap_ssl" name="imap_ssl">
|
||||
<option value="true" {% if settings.get('imap_ssl') == 'true' %}selected{% endif %}>SSL/TLS</option>
|
||||
<option value="false" {% if settings.get('imap_ssl') == 'false' %}selected{% endif %}>Keine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="imap_username">Benutzername</label>
|
||||
<input type="text" id="imap_username" name="imap_username"
|
||||
value="{{ settings.get('imap_username', '') }}" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="imap_password">Passwort</label>
|
||||
<input type="password" id="imap_password" name="imap_password"
|
||||
placeholder="{% if settings.get('imap_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="testImap()">
|
||||
<span class="btn-text">Verbindung testen & Ordner laden</span>
|
||||
<span class="btn-spinner" style="display:none;">Verbinde...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SMTP Einstellungen (Versand)</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="smtp_server">Server</label>
|
||||
<input type="text" id="smtp_server" name="smtp_server"
|
||||
value="{{ settings.get('smtp_server', '') }}" placeholder="smtp.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_port">Port</label>
|
||||
<input type="number" id="smtp_port" name="smtp_port"
|
||||
value="{{ settings.get('smtp_port', '587') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_ssl">Verschlüsselung</label>
|
||||
<select id="smtp_ssl" name="smtp_ssl">
|
||||
<option value="starttls" {% if settings.get('smtp_ssl') == 'starttls' %}selected{% endif %}>STARTTLS</option>
|
||||
<option value="ssl" {% if settings.get('smtp_ssl') == 'ssl' %}selected{% endif %}>SSL/TLS</option>
|
||||
<option value="none" {% if settings.get('smtp_ssl') == 'none' %}selected{% endif %}>Keine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_username">Benutzername</label>
|
||||
<input type="text" id="smtp_username" name="smtp_username"
|
||||
value="{{ settings.get('smtp_username', '') }}" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_password">Passwort</label>
|
||||
<input type="password" id="smtp_password" name="smtp_password"
|
||||
placeholder="{% if settings.get('smtp_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>LexOffice & Ordner</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="lexoffice_email">LexOffice Import-Emailadresse</label>
|
||||
<input type="email" id="lexoffice_email" name="lexoffice_email"
|
||||
value="{{ settings.get('lexoffice_email', '') }}" placeholder="import-xyz@lexoffice.de">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="source_folder">Eingangsordner (IMAP)</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="source_folder" name="source_folder"
|
||||
value="{{ settings.get('source_folder', 'Rechnungen') }}" placeholder="Rechnungen">
|
||||
<button type="button" class="btn btn-icon" onclick="openFolderPicker('source_folder')" title="Ordner auswählen">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="processed_folder">Verarbeitet-Ordner (IMAP)</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="processed_folder" name="processed_folder"
|
||||
value="{{ settings.get('processed_folder', 'Rechnungen/Verarbeitet') }}" placeholder="Rechnungen/Verarbeitet">
|
||||
<button type="button" class="btn btn-icon" onclick="openFolderPicker('processed_folder')" title="Ordner auswählen">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="testEmail()">
|
||||
<span class="btn-text">Test-Email an LexOffice senden</span>
|
||||
<span class="btn-spinner" style="display:none;">Sende...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SMB-Freigabe (Netzlaufwerk)</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="smb_enabled">SMB-Import</label>
|
||||
<select id="smb_enabled" name="smb_enabled">
|
||||
<option value="true" {% if settings.get('smb_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
||||
<option value="false" {% if settings.get('smb_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_mode">Verarbeitungsmodus</label>
|
||||
<select id="smb_mode" name="smb_mode">
|
||||
<option value="forward" {% if settings.get('smb_mode', 'forward') == 'forward' %}selected{% endif %}>Direkt weiterleiten</option>
|
||||
<option value="separator" {% if settings.get('smb_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="smb_server">Server</label>
|
||||
<input type="text" id="smb_server" name="smb_server"
|
||||
value="{{ settings.get('smb_server', '') }}" placeholder="192.168.1.100 oder nas.local">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_port">Port</label>
|
||||
<input type="number" id="smb_port" name="smb_port"
|
||||
value="{{ settings.get('smb_port', '445') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_username">Benutzername</label>
|
||||
<input type="text" id="smb_username" name="smb_username"
|
||||
value="{{ settings.get('smb_username', '') }}" placeholder="scanner">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_password">Passwort</label>
|
||||
<input type="password" id="smb_password" name="smb_password"
|
||||
placeholder="{% if settings.get('smb_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_domain">Domäne</label>
|
||||
<input type="text" id="smb_domain" name="smb_domain"
|
||||
value="{{ settings.get('smb_domain', '') }}" placeholder="WORKGROUP (optional)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_share">Freigabename</label>
|
||||
<input type="text" id="smb_share" name="smb_share"
|
||||
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_source_path">Quellordner</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="smb_source_path" name="smb_source_path"
|
||||
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
|
||||
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_source_path')" title="Ordner auswählen">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smb_processed_path">Verarbeitet-Ordner</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="smb_processed_path" name="smb_processed_path"
|
||||
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
|
||||
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="testSmb()">
|
||||
<span class="btn-text">Verbindung testen & Ordner laden</span>
|
||||
<span class="btn-spinner" style="display:none;">Verbinde...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Zeitplan</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="interval_minutes">Abruf-Intervall (Minuten)</label>
|
||||
<input type="number" id="interval_minutes" name="interval_minutes"
|
||||
value="{{ settings.get('interval_minutes', '5') }}" min="1" max="1440">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scheduler_enabled">Automatischer Abruf</label>
|
||||
<select id="scheduler_enabled" name="scheduler_enabled">
|
||||
<option value="true" {% if settings.get('scheduler_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
||||
<option value="false" {% if settings.get('scheduler_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fetch_since_date">Emails erst ab Datum verarbeiten</label>
|
||||
<input type="date" id="fetch_since_date" name="fetch_since_date"
|
||||
value="{{ settings.get('fetch_since_date', '') }}">
|
||||
<small class="text-muted">Leer = alle Emails im Ordner</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions-main">
|
||||
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
|
||||
<button type="button" class="btn btn-success" onclick="manualProcess()">
|
||||
<span class="btn-text">Jetzt abrufen</span>
|
||||
<span class="btn-spinner" style="display:none;">Verarbeite...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Alert-Bereich für JS-Meldungen -->
|
||||
<div id="jsAlert" class="alert" style="display:none;"></div>
|
||||
|
||||
{% if logs %}
|
||||
<div class="card">
|
||||
<h2>Letzte Verarbeitungen</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Betreff</th>
|
||||
<th>Absender</th>
|
||||
<th>Anhänge</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
||||
<td>{{ log.timestamp }}</td>
|
||||
<td>{{ log.email_subject or '-' }}</td>
|
||||
<td>{{ log.email_from or '-' }}</td>
|
||||
<td>{{ log.attachments_count }}</td>
|
||||
<td>
|
||||
{% if log.status == 'success' %}
|
||||
<span class="badge badge-success">OK</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error" title="{{ log.error_message or '' }}">Fehler</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Ordner-Auswahl Modal -->
|
||||
<div id="folderModal" class="modal-overlay" style="display:none;" onclick="closeFolderModal(event)">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="folderModalTitle">Ordner auswählen</h3>
|
||||
<button type="button" class="modal-close" onclick="closeFolderModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="folderList" class="folder-list"></div>
|
||||
<div id="folderLoading" class="text-muted" style="display:none;padding:1rem;">
|
||||
Verbinde und lade Ordner...
|
||||
</div>
|
||||
<div id="folderError" class="alert alert-error" style="display:none;margin:1rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMB Ordner-Auswahl Modal -->
|
||||
<div id="smbFolderModal" class="modal-overlay" style="display:none;" onclick="closeSmbFolderModal(event)">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="smbFolderModalTitle">SMB-Ordner auswählen</h3>
|
||||
<button type="button" class="modal-close" onclick="closeSmbFolderModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="smbFolderList" class="folder-list"></div>
|
||||
<div id="smbFolderLoading" class="text-muted" style="display:none;padding:1rem;">
|
||||
Verbinde und lade Ordner...
|
||||
</div>
|
||||
<div id="smbFolderError" 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;
|
||||
|
||||
function showAlert(message, type) {
|
||||
const el = document.getElementById('jsAlert');
|
||||
el.textContent = message;
|
||||
el.className = 'alert alert-' + type;
|
||||
el.style.display = 'block';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setTimeout(() => { el.style.display = 'none'; }, 8000);
|
||||
}
|
||||
|
||||
function setButtonLoading(btn, loading) {
|
||||
const text = btn.querySelector('.btn-text');
|
||||
const spinner = btn.querySelector('.btn-spinner');
|
||||
if (text && spinner) {
|
||||
text.style.display = loading ? 'none' : '';
|
||||
spinner.style.display = loading ? '' : 'none';
|
||||
}
|
||||
btn.disabled = loading;
|
||||
}
|
||||
|
||||
function getFormData() {
|
||||
return new FormData(document.getElementById('settingsForm'));
|
||||
}
|
||||
|
||||
async function testImap() {
|
||||
const btn = event.currentTarget;
|
||||
setButtonLoading(btn, true);
|
||||
try {
|
||||
const resp = await fetch('/api/test-imap', { method: 'POST', body: getFormData() });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
cachedFolders = data.folders;
|
||||
cachedDelimiter = data.delimiter || '.';
|
||||
showAlert('IMAP-Verbindung erfolgreich! Einstellungen gespeichert.', 'success');
|
||||
showFolderModal(null);
|
||||
} else {
|
||||
showAlert('IMAP-Verbindung fehlgeschlagen: ' + data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Fehler: ' + e.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testEmail() {
|
||||
const btn = event.currentTarget;
|
||||
setButtonLoading(btn, true);
|
||||
try {
|
||||
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const addr = document.getElementById('lexoffice_email').value;
|
||||
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success');
|
||||
} else {
|
||||
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Fehler: ' + e.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function manualProcess() {
|
||||
const btn = event.currentTarget;
|
||||
setButtonLoading(btn, true);
|
||||
try {
|
||||
const fd = getFormData();
|
||||
let msgs = [];
|
||||
let hasErrors = false;
|
||||
|
||||
// IMAP
|
||||
const resp = await fetch('/api/process', { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
msgs.push('IMAP-Fehler: ' + data.error);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
msgs.push(`IMAP: ${data.processed} weitergeleitet, ${data.skipped} übersprungen, ${data.errors} Fehler`);
|
||||
if (data.errors > 0) hasErrors = true;
|
||||
}
|
||||
|
||||
// SMB
|
||||
const smbResp = await fetch('/api/process-smb', { method: 'POST', body: fd });
|
||||
const smbData = await smbResp.json();
|
||||
if (smbData.error) {
|
||||
msgs.push('SMB-Fehler: ' + smbData.error);
|
||||
hasErrors = true;
|
||||
} else if (smbData.processed > 0 || smbData.errors > 0) {
|
||||
msgs.push(`SMB: ${smbData.processed} weitergeleitet, ${smbData.skipped} übersprungen, ${smbData.errors} Fehler`);
|
||||
if (smbData.errors > 0) hasErrors = true;
|
||||
}
|
||||
|
||||
showAlert(msgs.join(' | '), hasErrors ? 'warning' : 'success');
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} catch (e) {
|
||||
showAlert('Fehler: ' + e.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
function openFolderPicker(targetField) {
|
||||
folderTargetField = targetField;
|
||||
if (cachedFolders) {
|
||||
showFolderModal(targetField);
|
||||
} else {
|
||||
showFolderModalLoading(targetField);
|
||||
fetchFolders(targetField);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFolders(targetField) {
|
||||
try {
|
||||
const resp = await fetch('/api/test-imap', { method: 'POST', body: getFormData() });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
cachedFolders = data.folders;
|
||||
cachedDelimiter = data.delimiter || '.';
|
||||
showFolderModal(targetField);
|
||||
} else {
|
||||
showFolderModalError('IMAP-Verbindung fehlgeschlagen: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showFolderModalError('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showFolderModalLoading(targetField) {
|
||||
const modal = document.getElementById('folderModal');
|
||||
document.getElementById('folderModalTitle').textContent = 'Ordner auswählen';
|
||||
document.getElementById('folderList').innerHTML = '';
|
||||
document.getElementById('folderLoading').style.display = '';
|
||||
document.getElementById('folderError').style.display = 'none';
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showFolderModal(targetField) {
|
||||
if (targetField) folderTargetField = targetField;
|
||||
createOpenFor = null;
|
||||
const modal = document.getElementById('folderModal');
|
||||
document.getElementById('folderModalTitle').textContent = 'Ordner auswählen';
|
||||
document.getElementById('folderLoading').style.display = 'none';
|
||||
document.getElementById('folderError').style.display = 'none';
|
||||
|
||||
const list = document.getElementById('folderList');
|
||||
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
|
||||
|
||||
let html = '<div class="folder-picker-fields">';
|
||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingangsordner: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
|
||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
|
||||
html += '</div>';
|
||||
html += '<div class="folder-items">';
|
||||
if (cachedFolders && cachedFolders.length > 0) {
|
||||
cachedFolders.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="selectFolder(\'' + escapedFolder + '\')">';
|
||||
html += '<span class="folder-icon">📁</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(); toggleCreateInput(\'' + escapedFolder + '\')" title="Unterordner erstellen">📁+</button>';
|
||||
html += '</div>';
|
||||
// Placeholder for inline create input
|
||||
html += '<div id="create-row-' + CSS.escape(folder) + '" class="create-inline" style="display:none;"></div>';
|
||||
});
|
||||
} else {
|
||||
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
list.innerHTML = html;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function switchFolderTarget(field) {
|
||||
folderTargetField = field;
|
||||
showFolderModal(field);
|
||||
}
|
||||
|
||||
function showFolderModalError(msg) {
|
||||
document.getElementById('folderLoading').style.display = 'none';
|
||||
document.getElementById('folderError').textContent = msg;
|
||||
document.getElementById('folderError').style.display = '';
|
||||
}
|
||||
|
||||
function selectFolder(folder) {
|
||||
if (folderTargetField) {
|
||||
document.getElementById(folderTargetField).value = folder;
|
||||
}
|
||||
showFolderModal(folderTargetField);
|
||||
}
|
||||
|
||||
function toggleCreateInput(parentFolder) {
|
||||
// Close any previously open create row
|
||||
document.querySelectorAll('.create-inline').forEach(el => {
|
||||
if (el.id !== 'create-row-' + CSS.escape(parentFolder)) {
|
||||
el.style.display = 'none';
|
||||
el.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
const row = document.getElementById('create-row-' + CSS.escape(parentFolder));
|
||||
if (!row) return;
|
||||
|
||||
if (row.style.display !== 'none') {
|
||||
row.style.display = 'none';
|
||||
row.innerHTML = '';
|
||||
createOpenFor = null;
|
||||
return;
|
||||
}
|
||||
|
||||
createOpenFor = parentFolder;
|
||||
row.innerHTML =
|
||||
'<div class="create-folder-inline">' +
|
||||
'<span class="create-folder-prefix">' + esc(parentFolder) + cachedDelimiter + '</span>' +
|
||||
'<input type="text" class="create-folder-input" id="newSubfolderInput" placeholder="Name" autofocus>' +
|
||||
'<button type="button" class="btn btn-primary btn-sm" onclick="doCreateFolder(\'' + parentFolder.replace(/'/g, "\\'") + '\')">OK</button>' +
|
||||
'<button type="button" class="btn btn-secondary btn-sm" onclick="toggleCreateInput(\'' + parentFolder.replace(/'/g, "\\'") + '\')">Abbrechen</button>' +
|
||||
'</div>' +
|
||||
'<div id="createError" class="text-error" style="display:none;"></div>';
|
||||
row.style.display = '';
|
||||
|
||||
const input = document.getElementById('newSubfolderInput');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doCreateFolder(parentFolder);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
toggleCreateInput(parentFolder);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function doCreateFolder(parentFolder) {
|
||||
const input = document.getElementById('newSubfolderInput');
|
||||
const errorEl = document.getElementById('createError');
|
||||
if (!input) return;
|
||||
|
||||
const subName = input.value.trim();
|
||||
if (!subName) {
|
||||
errorEl.textContent = 'Bitte einen Namen eingeben.';
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const fullName = parentFolder + cachedDelimiter + subName;
|
||||
errorEl.style.display = 'none';
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/create-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_name: fullName })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
cachedFolders = data.folders;
|
||||
if (folderTargetField) {
|
||||
document.getElementById(folderTargetField).value = fullName;
|
||||
}
|
||||
showFolderModal(folderTargetField);
|
||||
} else {
|
||||
errorEl.textContent = data.error;
|
||||
errorEl.style.display = '';
|
||||
input.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeFolderModal(event) {
|
||||
if (event && event.target !== document.getElementById('folderModal')) return;
|
||||
document.getElementById('folderModal').style.display = 'none';
|
||||
cachedFolders = null;
|
||||
createOpenFor = null;
|
||||
}
|
||||
|
||||
// --- SMB Folder Picker ---
|
||||
let cachedSmbFolders = null;
|
||||
let smbFolderTargetField = null;
|
||||
|
||||
async function testSmb() {
|
||||
const btn = event.currentTarget;
|
||||
setButtonLoading(btn, true);
|
||||
try {
|
||||
const resp = await fetch('/api/test-smb', { method: 'POST', body: getFormData() });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
cachedSmbFolders = data.folders;
|
||||
showAlert('SMB-Verbindung erfolgreich! Einstellungen gespeichert.', 'success');
|
||||
showSmbFolderModal(null);
|
||||
} else {
|
||||
showAlert('SMB-Verbindung fehlgeschlagen: ' + data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Fehler: ' + e.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
function openSmbFolderPicker(targetField) {
|
||||
smbFolderTargetField = targetField;
|
||||
if (cachedSmbFolders) {
|
||||
showSmbFolderModal(targetField);
|
||||
} else {
|
||||
showSmbFolderModalLoading(targetField);
|
||||
fetchSmbFolders(targetField);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSmbFolders(targetField) {
|
||||
try {
|
||||
const resp = await fetch('/api/test-smb', { method: 'POST', body: getFormData() });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
cachedSmbFolders = data.folders;
|
||||
showSmbFolderModal(targetField);
|
||||
} else {
|
||||
showSmbFolderModalError('SMB-Verbindung fehlgeschlagen: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showSmbFolderModalError('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showSmbFolderModalLoading(targetField) {
|
||||
const modal = document.getElementById('smbFolderModal');
|
||||
document.getElementById('smbFolderList').innerHTML = '';
|
||||
document.getElementById('smbFolderLoading').style.display = '';
|
||||
document.getElementById('smbFolderError').style.display = 'none';
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showSmbFolderModal(targetField) {
|
||||
if (targetField) smbFolderTargetField = targetField;
|
||||
const modal = document.getElementById('smbFolderModal');
|
||||
document.getElementById('smbFolderLoading').style.display = 'none';
|
||||
document.getElementById('smbFolderError').style.display = 'none';
|
||||
|
||||
const list = document.getElementById('smbFolderList');
|
||||
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
|
||||
|
||||
let html = '<div class="folder-picker-fields">';
|
||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Quellordner: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
|
||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
|
||||
html += '</div>';
|
||||
html += '<div class="folder-items">';
|
||||
|
||||
// Root option for source path
|
||||
if (smbFolderTargetField === 'smb_source_path') {
|
||||
const isRoot = currentValue === '';
|
||||
html += '<div class="folder-row">';
|
||||
html += '<button type="button" class="folder-item' + (isRoot ? ' selected' : '') + '" onclick="selectSmbFolder(\'\')">';
|
||||
html += '<span class="folder-icon">📁</span> (Wurzel der Freigabe)';
|
||||
if (isRoot) html += ' <span class="badge badge-success">ausgewählt</span>';
|
||||
html += '</button>';
|
||||
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">📁</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">📁+</button>';
|
||||
html += '</div>';
|
||||
html += '<div id="smb-create-row-' + CSS.escape(folder) + '" class="create-inline" style="display:none;"></div>';
|
||||
});
|
||||
} 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(); toggleSmbCreateInput(\'\')" style="color:var(--primary);">';
|
||||
html += '<span class="folder-icon">📁+</span> Neuen Ordner erstellen';
|
||||
html += '</button>';
|
||||
html += '</div>';
|
||||
html += '<div id="smb-create-row-root" class="create-inline" style="display:none;"></div>';
|
||||
|
||||
html += '</div>';
|
||||
list.innerHTML = html;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function switchSmbFolderTarget(field) {
|
||||
smbFolderTargetField = field;
|
||||
showSmbFolderModal(field);
|
||||
}
|
||||
|
||||
function selectSmbFolder(folder) {
|
||||
if (smbFolderTargetField) {
|
||||
document.getElementById(smbFolderTargetField).value = folder;
|
||||
}
|
||||
showSmbFolderModal(smbFolderTargetField);
|
||||
}
|
||||
|
||||
function showSmbFolderModalError(msg) {
|
||||
document.getElementById('smbFolderLoading').style.display = 'none';
|
||||
document.getElementById('smbFolderError').textContent = msg;
|
||||
document.getElementById('smbFolderError').style.display = '';
|
||||
}
|
||||
|
||||
function toggleSmbCreateInput(parentFolder) {
|
||||
document.querySelectorAll('.create-inline[id^="smb-create-row"]').forEach(el => {
|
||||
const rowId = parentFolder === '' ? 'smb-create-row-root' : 'smb-create-row-' + CSS.escape(parentFolder);
|
||||
if (el.id !== rowId) {
|
||||
el.style.display = 'none';
|
||||
el.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
const rowId = parentFolder === '' ? 'smb-create-row-root' : 'smb-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="newSmbSubfolderInput" placeholder="Name" autofocus>' +
|
||||
'<button type="button" class="btn btn-primary btn-sm" onclick="doCreateSmbFolder(\'' + parentFolder.replace(/'/g, "\\'") + '\')">OK</button>' +
|
||||
'<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSmbCreateInput(\'' + parentFolder.replace(/'/g, "\\'") + '\')">Abbrechen</button>' +
|
||||
'</div>' +
|
||||
'<div id="smbCreateError" class="text-error" style="display:none;"></div>';
|
||||
row.style.display = '';
|
||||
|
||||
const input = document.getElementById('newSmbSubfolderInput');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doCreateSmbFolder(parentFolder); }
|
||||
if (e.key === 'Escape') { toggleSmbCreateInput(parentFolder); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function doCreateSmbFolder(parentFolder) {
|
||||
const input = document.getElementById('newSmbSubfolderInput');
|
||||
const errorEl = document.getElementById('smbCreateError');
|
||||
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-smb-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_name: fullName })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
cachedSmbFolders = data.folders;
|
||||
if (smbFolderTargetField) {
|
||||
document.getElementById(smbFolderTargetField).value = fullName;
|
||||
}
|
||||
showSmbFolderModal(smbFolderTargetField);
|
||||
} else {
|
||||
errorEl.textContent = data.error;
|
||||
errorEl.style.display = '';
|
||||
input.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeSmbFolderModal(event) {
|
||||
if (event && event.target !== document.getElementById('smbFolderModal')) return;
|
||||
document.getElementById('smbFolderModal').style.display = 'none';
|
||||
cachedSmbFolders = null;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
belegimport:
|
||||
build: .
|
||||
container_name: lexoffice-belegimport
|
||||
ports:
|
||||
- "8081:8000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- DB_PATH=/data/belegimport.db
|
||||
- TZ=Europe/Berlin
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
jinja2==3.1.5
|
||||
python-multipart==0.0.20
|
||||
aiosqlite==0.20.0
|
||||
apscheduler==3.11.0
|
||||
cryptography==44.0.0
|
||||
pypdf==5.1.0
|
||||
Pillow==11.1.0
|
||||
pyzbar==0.1.9
|
||||
PyMuPDF==1.25.3
|
||||
qrcode==8.0
|
||||
sse-starlette==2.2.1
|
||||
smbprotocol==1.14.0
|
||||
Loading…
Reference in New Issue