"""FlowDeck — OPML importer (v5.6.0, Phase 4). Imports an OPML outline (RSS readers, feed lists) as a collection of feeds. """ from __future__ import annotations import xml.etree.ElementTree as ET from typing import Any from app.services.importers.base import ( Importer, ImportResult, decode_text, make_collection, register_importer, ) _SCHEMA = [ {"name": "Title", "type": "title"}, {"name": "Feed URL", "type": "url"}, {"name": "Site URL", "type": "url"}, {"name": "Type", "type": "select", "options": [ {"name": "rss", "color": "orange"}, {"name": "folder", "color": "gray"}, ]}, {"name": "Folder", "type": "text"}, ] @register_importer class OpmlImporter(Importer): source_id = "opml" label = "OPML (flux RSS)" description = "Outline OPML → collection de flux (titre, URL, dossier)." extensions = (".opml", ".xml") order = 34 def detect(self, filename: str, data: bytes) -> bool: if filename.lower().endswith(".opml"): return True head = decode_text(data)[:1000].lower() return " ImportResult: result = ImportResult(source=self.source_id) rows: list[dict] = [] try: root = ET.fromstring(decode_text(data)) except ET.ParseError as exc: result.warn(f"OPML invalide : {exc}") return result.finalize() for outline in root.iter("outline"): attrs = {k.lower(): v for k, v in outline.attrib.items()} feed = attrs.get("xmlurl") title = attrs.get("title") or attrs.get("text") or feed or "" if not feed and not title: continue props: dict[str, Any] = {} if feed: props["Feed URL"] = feed props["Type"] = "rss" else: props["Type"] = "folder" if attrs.get("htmlurl"): props["Site URL"] = attrs["htmlurl"] folder = _folder_of(outline, root) if folder: props["Folder"] = folder rows.append({"title": title[:200] or "Feed", "properties": props}) result.pages.append(make_collection("OPML feeds", _SCHEMA, rows, source_path=filename)) result.stats["rows"] = len(rows) return result.finalize() def _folder_of(node: ET.Element, root: ET.Element) -> str: parents = {child: parent for parent in root.iter() for child in parent} parts: list[str] = [] current = parents.get(node) while current is not None: attrs = {k.lower(): v for k, v in current.attrib.items()} if not attrs.get("xmlurl"): label = attrs.get("title") or attrs.get("text") if label: parts.append(label) current = parents.get(current) return " / ".join(reversed(parts))