Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
161 lines
4.9 KiB
Python
161 lines
4.9 KiB
Python
"""Nextcloud WebDAV inbox sync.
|
|
|
|
Polls a Nextcloud folder via WebDAV (PROPFIND/GET) for new files,
|
|
downloads them for processing, and moves processed files to a processed/ subfolder.
|
|
"""
|
|
|
|
import logging
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urljoin
|
|
|
|
import httpx
|
|
|
|
from ingestion.config import IngestionConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# WebDAV XML namespace
|
|
DAV_NS = "DAV:"
|
|
|
|
|
|
class NextcloudInboxSync:
|
|
"""Sync files from a Nextcloud WebDAV folder."""
|
|
|
|
def __init__(self, config: IngestionConfig):
|
|
self.base_url = config.nextcloud_inbox_url.rstrip("/") + "/"
|
|
self.user = config.nextcloud_inbox_user
|
|
self.password = config.nextcloud_inbox_password
|
|
self.local_inbox = Path(config.inbox_dir)
|
|
self.local_inbox.mkdir(parents=True, exist_ok=True)
|
|
|
|
self._client = httpx.Client(
|
|
auth=(self.user, self.password),
|
|
timeout=30.0,
|
|
)
|
|
|
|
def poll(self) -> list[Path]:
|
|
"""Poll Nextcloud inbox for files and download them.
|
|
|
|
Returns:
|
|
List of local paths to downloaded files.
|
|
"""
|
|
try:
|
|
filenames = self._list_files()
|
|
except Exception as e:
|
|
logger.error("Failed to list Nextcloud inbox: %s", e)
|
|
return []
|
|
|
|
if not filenames:
|
|
logger.info("Nextcloud inbox is empty.")
|
|
return []
|
|
|
|
downloaded: list[Path] = []
|
|
for filename in filenames:
|
|
local_path = self.download(filename)
|
|
if local_path:
|
|
downloaded.append(local_path)
|
|
|
|
logger.info("Downloaded %d file(s) from Nextcloud.", len(downloaded))
|
|
return downloaded
|
|
|
|
def _list_files(self) -> list[str]:
|
|
"""PROPFIND to list files in the inbox folder."""
|
|
propfind_body = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:">
|
|
<d:prop>
|
|
<d:resourcetype/>
|
|
<d:getcontentlength/>
|
|
</d:prop>
|
|
</d:propfind>"""
|
|
|
|
response = self._client.request(
|
|
"PROPFIND",
|
|
self.base_url,
|
|
content=propfind_body,
|
|
headers={
|
|
"Content-Type": "application/xml",
|
|
"Depth": "1",
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
# Parse WebDAV XML response
|
|
root = ET.fromstring(response.text)
|
|
filenames: list[str] = []
|
|
|
|
for resp_elem in root.findall(f"{{{DAV_NS}}}response"):
|
|
href = resp_elem.findtext(f"{{{DAV_NS}}}href", "")
|
|
# Skip the folder itself (collections)
|
|
resourcetype = resp_elem.find(
|
|
f".//{{{DAV_NS}}}resourcetype/{{{DAV_NS}}}collection"
|
|
)
|
|
if resourcetype is not None:
|
|
continue
|
|
|
|
# Extract filename from href
|
|
filename = unquote(href.rstrip("/").split("/")[-1])
|
|
if filename and not filename.startswith("."):
|
|
filenames.append(filename)
|
|
|
|
return filenames
|
|
|
|
def download(self, filename: str) -> Path | None:
|
|
"""Download a file from Nextcloud inbox.
|
|
|
|
Args:
|
|
filename: Name of the file to download.
|
|
|
|
Returns:
|
|
Local path to the downloaded file, or None on failure.
|
|
"""
|
|
url = urljoin(self.base_url, filename)
|
|
try:
|
|
response = self._client.get(url)
|
|
response.raise_for_status()
|
|
|
|
local_path = self.local_inbox / filename
|
|
local_path.write_bytes(response.content)
|
|
logger.info("Downloaded: %s", filename)
|
|
return local_path
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to download %s: %s", filename, e)
|
|
return None
|
|
|
|
def mark_processed(self, filename: str) -> bool:
|
|
"""Move a processed file to the processed/ subfolder on Nextcloud.
|
|
|
|
Args:
|
|
filename: Name of the file to move.
|
|
|
|
Returns:
|
|
True if move succeeded, False otherwise.
|
|
"""
|
|
source_url = urljoin(self.base_url, filename)
|
|
dest_url = urljoin(self.base_url, f"processed/{filename}")
|
|
|
|
try:
|
|
# Ensure processed/ folder exists (MKCOL)
|
|
processed_url = urljoin(self.base_url, "processed/")
|
|
self._client.request("MKCOL", processed_url)
|
|
|
|
# MOVE the file
|
|
response = self._client.request(
|
|
"MOVE",
|
|
source_url,
|
|
headers={"Destination": dest_url, "Overwrite": "T"},
|
|
)
|
|
if response.status_code in (201, 204):
|
|
logger.info("Marked processed: %s", filename)
|
|
return True
|
|
else:
|
|
logger.warning(
|
|
"MOVE returned %d for %s", response.status_code, filename
|
|
)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.warning("Failed to mark %s as processed: %s", filename, e)
|
|
return False
|