"""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 = """ """ 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