Files
Orchestrator/privat/andreknie-privat/NoteGraph/ingestion/integrations/nextcloud.py

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