feat: group private apps into andreknie-privat
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user