"""OPML import/export. Feeds are exported as an OPML 2.0 outline grouped by ntfy topic. ntfy-specific options are stored as custom ``ntfy*`` attributes so an export/import round-trip preserves everything. Import also accepts plain OPML files from other readers: feeds without an ``ntfyTopic`` attribute inherit the parent outline's text as the topic (falling back to ``rss``). """ import xml.etree.ElementTree as ET def _int(value, default): try: return int(value) except (TypeError, ValueError): return default def feeds_to_opml(feeds): opml = ET.Element("opml", version="2.0") head = ET.SubElement(opml, "head") ET.SubElement(head, "title").text = "rss-bridge-ntfy feeds" body = ET.SubElement(opml, "body") # Group feeds by topic, preserving insertion order. groups = {} for feed in feeds: groups.setdefault(feed.get("topic", "") or "feeds", []).append(feed) for topic, items in groups.items(): group = ET.SubElement(body, "outline", text=topic, title=topic) for feed in items: label = feed.get("name") or feed.get("url", "") ET.SubElement(group, "outline", **{ "type": "rss", "text": label, "title": label, "xmlUrl": feed.get("url", ""), "htmlUrl": feed.get("url", ""), "ntfyTopic": feed.get("topic", "") or "", "ntfyPriority": str(feed.get("priority", 3)), "ntfyIcon": feed.get("icon", "") or "", "ntfyQuietHours": feed.get("quiet_hours", "") or "", "ntfyQuietPriority": str(feed.get("quiet_priority", 1)), "ntfyInclude": feed.get("include_regex", "") or "", "ntfyExclude": feed.get("exclude_regex", "") or "", "ntfyEnabled": "true" if feed.get("enabled", True) else "false", }) xml = ET.tostring(opml, encoding="unicode") return '\n' + xml def opml_to_feeds(xml_str): root = ET.fromstring(xml_str) body = root.find("body") if body is None: return [] feeds = [] def walk(node, parent_topic): for outline in node.findall("outline"): xml_url = outline.get("xmlUrl") or outline.get("xmlurl") text = outline.get("text") or outline.get("title") or "" if xml_url: topic = outline.get("ntfyTopic") or parent_topic or text or "rss" feeds.append({ "name": text or xml_url, "url": xml_url, "topic": topic, "priority": _int(outline.get("ntfyPriority"), 3), "icon": outline.get("ntfyIcon", "") or "", "quiet_hours": outline.get("ntfyQuietHours", "") or "", "quiet_priority": _int(outline.get("ntfyQuietPriority"), 1), "include_regex": outline.get("ntfyInclude", "") or "", "exclude_regex": outline.get("ntfyExclude", "") or "", "enabled": (outline.get("ntfyEnabled", "true").lower() != "false"), }) # A container outline (no xmlUrl) defines the topic for its children. child_topic = text if (not xml_url and text) else parent_topic walk(outline, child_topic) walk(body, "") return feeds